first pass at using klayout method. Many bugs likely remain

This commit is contained in:
Jan Petykiewicz 2023-06-08 01:02:43 -07:00
parent 0adb5e6cf8
commit 9017984b4b
20 changed files with 561 additions and 1002 deletions

29
.flake8 Normal file
View File

@ -0,0 +1,29 @@
[flake8]
ignore =
# E501 line too long
E501,
# W391 newlines at EOF
W391,
# E241 multiple spaces after comma
E241,
# E302 expected 2 newlines
E302,
# W503 line break before binary operator (to be deprecated)
W503,
# E265 block comment should start with '# '
E265,
# E123 closing bracket does not match indentation of opening bracket's line
E123,
# E124 closing bracket does not match visual indentation
E124,
# E221 multiple spaces before operator
E221,
# E201 whitespace after '['
E201,
# E741 ambiguous variable name 'I'
E741,
per-file-ignores =
# F401 import without use
*/__init__.py: F401,

View File

@ -5,10 +5,7 @@ Example code for checking connectivity in a layout by using
from pprint import pformat
import logging
from masque.file import gdsii, oasis
import snarled
import snarled.interfaces.masque
logging.basicConfig()
@ -21,20 +18,14 @@ connectivity = [
((2, 0), (2, 3), (3, 0)), #M2 to M3 (via V23)
]
labels_map = {
(1, 0): (1, 0),
(2, 0): (2, 0),
(3, 0): (3, 0),
}
#cells, props = gdsii.readfile('connectivity.gds')
cells, props = oasis.readfile('connectivity.oas')
topcell = cells['top']
filename = 'connectivity.oas'
get_layer = snarled.interfaces.masque.prepare_cell(topcell)
nets_info = snarled.trace_connectivity(get_layer, connectivity)
result = snarled.trace_layout(filename, connectivity, topcell='top', labels_map=labels_map)
print('\nFinal nets:')
print([kk for kk in sorted(nets_info.nets.keys()) if isinstance(kk.name, str)])
print('\nShorted net sets:')
for short in nets_info.get_shorted_nets():
print('(' + ','.join([repr(nn) for nn in sorted(list(short))]) + ')')
print('\nOpen nets:')
print(pformat(dict(nets_info.get_open_nets())))
print('Result:\n', pformat(result))

View File

@ -0,0 +1,3 @@
M1, V12, M2
M1, V13, M3
M2, V23, M3

6
examples/layermap.txt Normal file
View File

@ -0,0 +1,6 @@
1/0:M1
2/0:M2
3/0:M3
1/2:V12
1/3:V13
2/3:V23

5
examples/run.sh Normal file
View File

@ -0,0 +1,5 @@
#!/usr/bin/env bash
cd $(dirname -- "$0") # cd to this script's parent directory
snarled connectivity.oas connectivity.txt -l layermap.txt

53
pyproject.toml Normal file
View File

@ -0,0 +1,53 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "snarled"
description = "CAD layout electrical connectivity checker"
readme = "README.md"
license = { file = "LICENSE.md" }
authors = [
{ name="Jan Petykiewicz", email="jan@mpxd.net" },
]
homepage = "https://mpxd.net/code/jan/snarled"
repository = "https://mpxd.net/code/jan/snarled"
keywords = [
"layout",
"design",
"CAD",
"EDA",
"electronics",
"photonics",
"IC",
"mask",
"pattern",
"drawing",
"lvs",
"connectivity",
"short",
"unintentional",
"label",
"schematic",
"verification",
"checking",
]
classifiers = [
"Programming Language :: Python :: 3",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"Intended Audience :: Information Technology",
"Intended Audience :: Manufacturing",
"Intended Audience :: Science/Research",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
]
requires-python = ">=3.10"
dynamic = ["version"]
dependencies = [
"klayout~=0.28",
]
[tool.hatch.version]
path = "snarled/__init__.py"

View File

@ -1,63 +0,0 @@
#!/usr/bin/env python3
from setuptools import setup, find_packages
with open('README.md', 'rt') as f:
long_description = f.read()
with open('snarled/VERSION.py', 'rt') as f:
version = f.readlines()[2].strip()
setup(name='snarled',
version=version,
description='CAD layout electrical connectivity checker',
long_description=long_description,
long_description_content_type='text/markdown',
author='Jan Petykiewicz',
author_email='jan@mpxd.net',
url='https://mpxd.net/code/jan/snarled',
packages=find_packages(),
package_data={
'snarled': ['py.typed',
]
},
install_requires=[
'numpy',
'pyclipper',
],
extras_require={
'masque': ['masque'],
'oasis': ['fatamorgana>=0.7'],
'gdsii': ['klamath>=1.0'],
},
classifiers=[
'Programming Language :: Python :: 3',
'Development Status :: 4 - Beta',
'Intended Audience :: Developers',
'Intended Audience :: Information Technology',
'Intended Audience :: Manufacturing',
'Intended Audience :: Science/Research',
'License :: OSI Approved :: GNU General Public License v3 (GPLv3)',
'Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)',
],
keywords=[
'layout',
'design',
'CAD',
'EDA',
'electronics',
'photonics',
'IC',
'mask',
'pattern',
'drawing',
'lvs',
'connectivity',
'short',
'unintentional',
'label',
'schematic',
],
)

View File

@ -1,4 +0,0 @@
""" VERSION defintion. THIS FILE IS MANUALLY PARSED BY setup.py and REQUIRES A SPECIFIC FORMAT """
__version__ = '''
0.6
'''.strip()

View File

@ -7,17 +7,12 @@ Layout connectivity checker.
`snarled` is a python package for checking electrical connectivity in multi-layer layouts.
It is intended to be "poor-man's LVS" (layout-versus-schematic), for when poverty
has deprived the man of both a schematic and a better connectivity tool.
has deprived the man of a schematic and a better connectivity tool.
The main functionality is in `trace_connectivity`.
Useful classes, namely `NetsInfo` and `NetName`, are in `snarled.tracker`.
`snarled.interfaces` contains helper code for interfacing with other packages.
The main functionality is in `trace`.
`__main__.py` details the command-line interface.
"""
from .main import trace_connectivity, trace_connectivity_preloaded
from .tracker import NetsInfo, NetName
from . import interfaces
from .trace import TraceResult, trace_layout
__author__ = 'Jan Petykiewicz'
from .VERSION import __version__
__version__ = '1.0'

79
snarled/__main__.py Normal file
View File

@ -0,0 +1,79 @@
from typing import Any
import argparse
import logging
from pprint import pformat
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
parser = argparse.ArgumentParser(
prog='snarled',
description='layout connectivity checker',
)
parser.add_argument('file_path')
parser.add_argument('connectivity_path')
parser.add_argument('-m', '--layermap')
parser.add_argument('-t', '--top')
parser.add_argument('-p', '--labels-remap')
parser.add_argument('-l', '--lfile-path')
parser.add_argument('-r', '--lremap')
parser.add_argument('-n', '--llayermap')
parser.add_argument('-s', '--ltop')
parser.add_argument('-o', '--output')
parser.add_argument('-u', '--raw-label-names', action='store_true')
args = parser.parse_args()
filepath = args.file_path
connectivity = utils.read_connectivity(args.connectivity_path)
kwargs: dict[str, Any] = {}
if args.layermap:
kwargs['layer_map'] = utils.read_layermap(args.layermap)
if args.top:
kwargs['topcell'] = args.top
if args.labels_remap:
kwargs['labels_remap'] = utils.read_remap(args.labels_remap)
if args.lfile_path:
kwargs['lfile_path'] = args.lfile_path
kwargs['lfile_map'] = utils.read_remap(args.lremap)
if args.llayermap:
kwargs['lfile_layermap'] = utils.read_layermap(args.llayermap)
if args.ltop:
kwargs['lfile_topcell'] = args.ltop
if args.output:
kwargs['output_path'] = args.output
if not args.raw_label_names:
def parse_label(string: str) -> str:
try:
parts = string.split('_')
_part_id = int(parts[-1]) # must succeed to return here
return '_'.join(parts[:-1])
except Exception:
return string
kwargs['parse_label'] = parse_label
result = trace_layout(
filepath=filepath,
connectivity=connectivity,
**kwargs,
)
print('Nets: ', pformat(result.nets))
print('Opens: ', pformat(result.opens))
print('Shorts: ', pformat(result.shorts))

View File

@ -1,64 +0,0 @@
"""
Wrappers to simplify some pyclipper functions
"""
from typing import Sequence, Optional, List
from numpy.typing import ArrayLike
from pyclipper import (
Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO, PFT_EVENODD,
PyPolyNode, CT_DIFFERENCE,
)
from .types import contour_t
def union_nonzero(shapes: Sequence[ArrayLike]) -> Optional[PyPolyNode]:
if not shapes:
return None
pc = Pyclipper()
pc.AddPaths(shapes, PT_CLIP, closed=True)
result = pc.Execute2(CT_UNION, PFT_NONZERO, PFT_NONZERO)
return result
def union_evenodd(shapes: Sequence[ArrayLike]) -> List[contour_t]:
if not shapes:
return []
pc = Pyclipper()
pc.AddPaths(shapes, PT_CLIP, closed=True)
return pc.Execute(CT_UNION, PFT_EVENODD, PFT_EVENODD)
def intersection_evenodd(
subject_shapes: Sequence[ArrayLike],
clip_shapes: Sequence[ArrayLike],
) -> List[contour_t]:
if not subject_shapes or not clip_shapes:
return []
pc = Pyclipper()
pc.AddPaths(subject_shapes, PT_SUBJECT, closed=True)
pc.AddPaths(clip_shapes, PT_CLIP, closed=True)
return pc.Execute(CT_INTERSECTION, PFT_EVENODD, PFT_EVENODD)
def difference_evenodd(
subject_shapes: Sequence[ArrayLike],
clip_shapes: Sequence[ArrayLike],
) -> List[contour_t]:
if not subject_shapes:
return []
if not clip_shapes:
return subject_shapes
pc = Pyclipper()
pc.AddPaths(subject_shapes, PT_SUBJECT, closed=True)
pc.AddPaths(clip_shapes, PT_CLIP, closed=True)
return pc.Execute(CT_DIFFERENCE, PFT_EVENODD, PFT_EVENODD)
def hier2oriented(polys: Sequence[PyPolyNode]) -> List[ArrayLike]:
contours = []
for poly in polys:
contours.append(poly.Contour)
contours += [hole.Contour for hole in poly.Childs]
return contours

View File

@ -1,127 +0,0 @@
"""
Functionality for extracting geometry and label info from `masque` patterns.
"""
from typing import Sequence, Dict, List, Any, Tuple, Optional, Mapping, Callable
from collections import defaultdict
import numpy
from numpy.typing import NDArray
from masque import Pattern
from masque.file import oasis, gdsii
from masque.shapes import Polygon
from ..types import layer_t
from ..utils import connectivity2layers
def prepare_cell(
cell: Pattern,
label_mapping: Optional[Mapping[layer_t, layer_t]] = None,
) -> Callable[[layer_t], Tuple[
List[NDArray[numpy.float64]],
List[Tuple[float, float, str]]
]]:
"""
Generate a function for extracting `polys` and `labels` from a `masque.Pattern`.
The returned function can be passed to `snarled.trace_connectivity`.
Args:
cell: A `masque` `Pattern` object. Usually your topcell.
label_mapping: A mapping of `{label_layer: metal_layer}`. This allows labels
to refer to nets on metal layers without the labels themselves being on
that layer.
Default `None` reads labels from the same layer as the geometry.
Returns:
`get_layer` function, to be passed to `snarled.trace_connectivity`.
"""
def get_layer(
layer: layer_t,
) -> Tuple[
List[NDArray[numpy.float64]],
List[Tuple[float, float, str]]
]:
if label_mapping is None:
label_layers = {layer: layer}
else:
label_layers = {label_layer for label_layer, metal_layer in label_mapping.items()
if metal_layer == layer}
subset = cell.deepcopy().subset( # TODO add single-op subset-and-copy, to avoid copying unwanted stuff
shapes_func=lambda ss: ss.layer == layer,
labels_func=lambda ll: ll.layer in label_layers,
subpatterns_func=lambda ss: True,
recursive=True,
)
polygonized = subset.polygonize() # Polygonize Path shapes
flat = polygonized.flatten()
# load polygons
polys = []
for ss in flat.shapes:
assert(isinstance(ss, Polygon))
if ss.repetition is None:
displacements = [(0, 0)]
else:
displacements = ss.repetition.displacements
for displacement in displacements:
polys.append(
ss.vertices + ss.offset + displacement
)
# load metal labels
labels = []
for ll in flat.labels:
if ll.repetition is None:
displacements = [(0, 0)]
else:
displacements = ll.repetition.displacements
for displacement in displacements:
offset = ll.offset + displacement
labels.append((*offset, ll.string))
return polys, labels
return get_layer
def read_cell(
cell: Pattern,
connectivity: Sequence[Tuple[layer_t, Optional[layer_t], layer_t]],
label_mapping: Optional[Mapping[layer_t, layer_t]] = None,
) -> Tuple[
defaultdict[layer_t, List[NDArray[numpy.float64]]],
defaultdict[layer_t, List[Tuple[float, float, str]]]]:
"""
Extract `polys` and `labels` from a `masque.Pattern`.
This function extracts the data needed by `snarled.trace_connectivity`.
Args:
cell: A `masque` `Pattern` object. Usually your topcell.
connectivity: A sequence of 3-tuples specifying the layer connectivity.
Same as what is provided to `snarled.trace_connectivity`.
label_mapping: A mapping of `{label_layer: metal_layer}`. This allows labels
to refer to nets on metal layers without the labels themselves being on
that layer.
Returns:
`polys` and `labels` data structures, to be passed to `snarled.trace_connectivity`.
"""
metal_layers, via_layers = connectivity2layers(connectivity)
poly_layers = metal_layers | via_layers
get_layer = prepare_cell(cell, label_mapping)
polys = defaultdict(list)
labels = defaultdict(list)
for layer in poly_layers:
polys[layer], labels[layer] = get_layer(layer)
return polys, labels

View File

@ -1,369 +0,0 @@
"""
Main connectivity-checking functionality for `snarled`
"""
from typing import Tuple, List, Dict, Set, Optional, Union, Sequence, Mapping, Callable
from collections import defaultdict
from pprint import pformat
from concurrent.futures import ThreadPoolExecutor
import logging
import numpy
from numpy.typing import NDArray, ArrayLike
from pyclipper import scale_to_clipper, scale_from_clipper, PyPolyNode
from .types import connectivity_t, layer_t, contour_t
from .poly import poly_contains_points, intersects
from .clipper import union_nonzero, union_evenodd, intersection_evenodd, difference_evenodd, hier2oriented
from .tracker import NetsInfo, NetName
from .utils import connectivity2layers
logger = logging.getLogger(__name__)
def trace_connectivity_preloaded(
polys: Mapping[layer_t, Sequence[ArrayLike]],
labels: Mapping[layer_t, Sequence[Tuple[float, float, str]]],
connectivity: Sequence[Tuple[layer_t, Optional[layer_t], layer_t]],
clipper_scale_factor: int = int(2 ** 24),
) -> NetsInfo:
"""
Analyze the electrical connectivity of the provided layout.
The resulting `NetsInfo` will contain only disjoint `nets`, and its `net_aliases` can be used to
understand which nets are shorted (and therefore known by more than one name).
Args:
polys: A full description of all conducting paths in the layout. Consists of lists of polygons
(Nx2 arrays of vertices), indexed by layer. The structure looks roughly like
`{layer0: [poly0, poly1, ..., [(x0, y0), (x1, y1), ...]], ...}`
labels: A list of "named points" which are used to assign names to the nets they touch.
A collection of lists of (x, y, name) tuples, indexed *by the layer they target*.
`{layer0: [(x0, y0, name0), (x1, y1, name1), ...], ...}`
connectivity: A sequence of 3-tuples specifying the electrical connectivity between layers.
Each 3-tuple looks like `(top_layer, via_layer, bottom_layer)` and indicates that
`top_layer` and `bottom_layer` are electrically connected at any location where
shapes are present on all three (top, via, and bottom) layers.
`via_layer` may be `None`, in which case any overlap between shapes on `top_layer`
and `bottom_layer` is automatically considered a short (with no third shape necessary).
clipper_scale_factor: `pyclipper` uses 64-bit integer math, while we accept either floats or ints.
The coordinates from `polys` are scaled by this factor to put them roughly in the middle of
the range `pyclipper` wants; you may need to adjust this if you are already using coordinates
with large integer values.
Returns:
`NetsInfo` object describing the various nets and their connectivities.
"""
def get_layer(layer: layer_t) -> Tuple[Sequence[ArrayLike], Sequence[Tuple[float, float, str]]]:
return polys[layer], labels[layer]
return trace_connectivity(get_layer, connectivity, clipper_scale_factor)
def trace_connectivity(
get_layer: Callable[[layer_t], Tuple[Sequence[ArrayLike], Sequence[Tuple[float, float, str]]]],
connectivity: Sequence[Tuple[layer_t, Optional[layer_t], layer_t]],
clipper_scale_factor: int = int(2 ** 24),
) -> NetsInfo:
"""
Analyze the electrical connectivity of a layout.
The resulting `NetsInfo` will contain only disjoint `nets`, and its `net_aliases` can be used to
understand which nets are shorted (and therefore known by more than one name).
This function attempts to reduce memory usage by lazy-loading layout data (layer-by-layer) and
pruning away layers for which all interactions have already been computed.
TODO: In the future, this will be extended to cover partial loading of spatial extents in
addition to layers.
Args:
get_layer: When called, `polys, labels = get_layer(layer)` should return the geometry and labels
on that layer. Returns
polys, A list of polygons (Nx2 arrays of vertices) on the layer. The structure looks like
`[poly0, poly1, ..., [(x0, y0), (x1, y1), ...]]`
labels, A list of "named points" which are used to assign names to the nets they touch.
A list of (x, y, name) tuples targetting this layer.
`[(x0, y0, name0), (x1, y1, name1), ...]`
connectivity: A sequence of 3-tuples specifying the electrical connectivity between layers.
Each 3-tuple looks like `(top_layer, via_layer, bottom_layer)` and indicates that
`top_layer` and `bottom_layer` are electrically connected at any location where
shapes are present on all three (top, via, and bottom) layers.
`via_layer` may be `None`, in which case any overlap between shapes on `top_layer`
and `bottom_layer` is automatically considered a short (with no third shape necessary).
NOTE that the order in which connectivity is specified (i.e. top-level ordering of the
tuples) directly sets the order in which the layers are loaded and merged, and thus
has a significant impact on memory usage by determining when layers can be pruned away.
Try to group entries by the layers they affect!
clipper_scale_factor: `pyclipper` uses 64-bit integer math, while we accept either floats or ints.
The coordinates from `polys` are scaled by this factor to put them roughly in the middle of
the range `pyclipper` wants; you may need to adjust this if you are already using coordinates
with large integer values.
Returns:
`NetsInfo` object describing the various nets and their connectivities.
"""
loaded_layers = set()
nets_info = NetsInfo()
for ii, (top_layer, via_layer, bot_layer) in enumerate(connectivity):
logger.info(f'{ii}, {top_layer}, {via_layer}, {bot_layer}')
for metal_layer in (top_layer, bot_layer):
if metal_layer in loaded_layers:
continue
# Load and run initial union on each layer
raw_polys, labels = get_layer(metal_layer)
polys = union_input_polys(scale_to_clipper(raw_polys, clipper_scale_factor))
# Check each polygon for labels, and assign it to a net (possibly anonymous).
nets_on_layer, merge_groups = label_polys(polys, labels, clipper_scale_factor)
for name, net_polys in nets_on_layer.items():
nets_info.nets[name][metal_layer] += hier2oriented(net_polys)
# Merge any nets that were shorted by having their labels on the same polygon
for group in merge_groups:
net_names = set(nn.name for nn in group)
if len(net_names) > 1:
logger.warning(f'Nets {net_names} are shorted on layer {metal_layer}')
first_net, *defunct_nets = group
for defunct_net in defunct_nets:
nets_info.merge(first_net, defunct_net)
loaded_layers.add(metal_layer)
# Load and union vias
via_raw_polys, _labels = get_layer(via_layer)
via_union = union_input_polys(scale_to_clipper(via_raw_polys, clipper_scale_factor))
via_polylists = scale_from_clipper(hier2oriented(via_union), clipper_scale_factor)
via_polys = [numpy.array(vv) for vv in via_polylists]
# Figure out which nets are shorted by vias, then merge them
merge_pairs = find_merge_pairs(nets_info.nets, top_layer, bot_layer, via_polys, clipper_scale_factor)
for net_a, net_b in merge_pairs:
nets_info.merge(net_a, net_b)
remaining_layers = set()
for layer_a, _, layer_b in connectivity[ii + 1:]:
remaining_layers.add(layer_a)
remaining_layers.add(layer_b)
finished_layers = loaded_layers - remaining_layers
for layer in finished_layers:
nets_info.prune(layer)
loaded_layers.remove(layer)
return nets_info
def union_input_polys(polys: Sequence[ArrayLike]) -> List[PyPolyNode]:
"""
Perform a union operation on the provided sequence of polygons, and return
a list of `PyPolyNode`s corresponding to all of the outer (i.e. non-hole)
contours.
Note that while islands are "outer" contours and returned in the list, they
also are still available through the `.Childs` property of the "hole" they
appear in. Meanwhile, "hole" contours are only accessible through the `.Childs`
property of their parent "outer" contour, and are not returned in the list.
Args:
polys: A sequence of polygons, `[[(x0, y0), (x1, y1), ...], poly1, poly2, ...]`
Polygons may be implicitly closed.
Returns:
List of PyPolyNodes, representing all "outer" contours (including islands) in
the union of `polys`.
"""
for poly in polys:
if (numpy.abs(poly) % 1).any():
logger.warning('Warning: union_polys got non-integer coordinates; all values will be truncated.')
break
#TODO: check if we need to reverse the order of points in some polygons
# via sum((x2-x1)(y2+y1)) (-ve means ccw)
poly_tree = union_nonzero(polys)
if poly_tree is None:
return []
# Partially flatten the tree, reclassifying all the "outer" (non-hole) nodes as new root nodes
unvisited_nodes = [poly_tree]
outer_nodes = []
while unvisited_nodes:
node = unvisited_nodes.pop() # node will be the tree parent node (a container), or a hole
for poly in node.Childs:
outer_nodes.append(poly)
for hole in poly.Childs: # type: ignore
unvisited_nodes.append(hole)
return outer_nodes
def label_polys(
polys: Sequence[PyPolyNode],
labels: Sequence[Tuple[float, float, str]],
clipper_scale_factor: int,
) -> Tuple[
defaultdict[NetName, List[PyPolyNode]],
List[List[NetName]]
]:
merge_groups = []
point_xys = []
point_names = []
nets = defaultdict(list)
for x, y, point_name in labels:
point_xys.append((x, y))
point_names.append(point_name)
for poly in polys:
found_nets = label_poly(poly, point_xys, point_names, clipper_scale_factor)
if found_nets:
name = NetName(found_nets[0])
else:
name = NetName() # Anonymous net
nets[name].append(poly)
if len(found_nets) > 1:
# Found a short
poly = pformat(scale_from_clipper(poly.Contour, clipper_scale_factor))
merge_groups.append([name] + [NetName(nn) for nn in found_nets[1:]])
return nets, merge_groups
def label_poly(
poly: PyPolyNode,
point_xys: ArrayLike,
point_names: Sequence[str],
clipper_scale_factor: int,
) -> List[str]:
"""
Given a `PyPolyNode` (a polygon, possibly with holes) and a sequence of named points,
return the list of point names contained inside the polygon.
Args:
poly: A polygon, possibly with holes. "Islands" inside the holes (and deeper-nested
structures) are not considered (i.e. only one non-hole contour is considered).
point_xys: A sequence of point coordinates (Nx2, `[(x0, y0), (x1, y1), ...]`).
point_names: A sequence of point names (same length N as point_xys)
clipper_scale_factor: The PyPolyNode structure is from `pyclipper` and likely has
a scale factor applied in order to use integer arithmetic. Due to precision
limitations in `poly_contains_points`, it's prefereable to undo this scaling
rather than asking for similarly-scaled `point_xys` coordinates.
NOTE: This could be fixed by using `numpy.longdouble` in
`poly_contains_points`, but the exact length of long-doubles is platform-
dependent and so probably best avoided.
Result:
All the `point_names` which correspond to points inside the polygon (but not in
its holes).
"""
if not point_names:
return []
poly_contour = scale_from_clipper(poly.Contour, clipper_scale_factor)
inside = poly_contains_points(poly_contour, point_xys)
for hole in poly.Childs:
hole_contour = scale_from_clipper(hole.Contour, clipper_scale_factor)
inside &= ~poly_contains_points(hole_contour, point_xys)
inside_nets = sorted([net_name for net_name, ii in zip(point_names, inside) if ii])
if inside.any():
return inside_nets
else:
return []
def find_merge_pairs(
nets: Mapping[NetName, Mapping[layer_t, Sequence[contour_t]]],
top_layer: layer_t,
bot_layer: layer_t,
via_polys: Optional[Sequence[contour_t]],
clipper_scale_factor: int,
) -> Set[Tuple[NetName, NetName]]:
"""
Given a collection of (possibly anonymous) nets, figure out which pairs of
nets are shorted through a via (and thus should be merged).
Args:
nets: A collection of all nets (seqences of polygons in mappings indexed by `NetName`
and layer). See `NetsInfo.nets`.
top_layer: Layer name of first layer
bot_layer: Layer name of second layer
via_polys: Sequence of via contours. `None` denotes to vias necessary (overlap is sufficent).
Returns:
A set containing pairs of `NetName`s for each pair of nets which are shorted.
"""
merge_pairs = set()
if via_polys is not None and not via_polys:
logger.warning(f'No vias between layers {top_layer}, {bot_layer}')
return merge_pairs
tested_pairs = set()
with ThreadPoolExecutor() as executor:
for top_name in nets.keys():
top_polys = nets[top_name][top_layer]
if not top_polys:
continue
for bot_name in nets.keys():
if bot_name == top_name:
continue
name_pair: Tuple[NetName, NetName] = tuple(sorted((top_name, bot_name))) #type: ignore
if name_pair in tested_pairs:
continue
tested_pairs.add(name_pair)
bot_polys = nets[bot_name][bot_layer]
if not bot_polys:
continue
executor.submit(check_overlap, top_polys, via_polys, bot_polys, clipper_scale_factor,
lambda np=name_pair: merge_pairs.add(np))
return merge_pairs
def check_overlap(
top_polys: Sequence[contour_t],
via_polys: Optional[Sequence[NDArray[numpy.float64]]],
bot_polys: Sequence[contour_t],
clipper_scale_factor: int,
action: Callable[[], None],
) -> None:
"""
Check for interaction between top and bottom polys, mediated by via polys if present.
"""
if via_polys is not None:
top_bot = intersection_evenodd(top_polys, bot_polys)
descaled = scale_from_clipper(top_bot, clipper_scale_factor)
overlap = check_any_intersection(descaled, via_polys)
# overlap = intersection_evenodd(top_bot, via_polys)
# via_polys = difference_evenodd(via_polys, overlap) # reduce set of via polys for future nets
else:
# overlap = intersection_evenodd(top_polys, bot_polys) # TODO verify there aren't any suspicious corner cases for this
overlap = check_any_intersection(
scale_from_clipper(top_polys, clipper_scale_factor),
scale_from_clipper(bot_polys, clipper_scale_factor))
if overlap:
action()
def check_any_intersection(polys_a, polys_b) -> bool:
for poly_a in polys_a:
for poly_b in polys_b:
if intersects(poly_a, poly_b):
return True
return False

View File

@ -1,157 +0,0 @@
"""
Utilities for working with polygons
"""
import numpy
from numpy.typing import NDArray, ArrayLike
def poly_contains_points(
vertices: ArrayLike,
points: ArrayLike,
include_boundary: bool = True,
) -> NDArray[numpy.int_]:
"""
Tests whether the provided points are inside the implicitly closed polygon
described by the provided list of vertices.
Args:
vertices: Nx2 Arraylike of form [[x0, y0], [x1, y1], ...], describing an implicitly-
closed polygon. Note that this should include any offsets.
points: Nx2 ArrayLike of form [[x0, y0], [x1, y1], ...] containing the points to test.
include_boundary: True if points on the boundary should be count as inside the shape.
Default True.
Returns:
ndarray of booleans, [point0_is_in_shape, point1_is_in_shape, ...]
"""
points = numpy.array(points, copy=False)
vertices = numpy.array(vertices, copy=False)
if points.size == 0:
return numpy.zeros(0)
min_bounds = numpy.min(vertices, axis=0)[None, :]
max_bounds = numpy.max(vertices, axis=0)[None, :]
trivially_outside = ((points < min_bounds).any(axis=1)
| (points > max_bounds).any(axis=1))
nontrivial = ~trivially_outside
if trivially_outside.all():
inside = numpy.zeros_like(trivially_outside, dtype=bool)
return inside
ntpts = points[None, nontrivial, :] # nontrivial points, along axis 1 of ndarray
verts = vertices[:, None, :] # vertices, along axis 0
xydiff = ntpts - verts # Expands into (n_vertices, n_ntpts, 2)
y0_le = xydiff[:, :, 1] >= 0 # y_point >= y_vertex (axes 0, 1 for all points & vertices)
y1_le = numpy.roll(y0_le, -1, axis=0) # same thing for next vertex
upward = y0_le & ~y1_le # edge passes point y coord going upwards
downward = ~y0_le & y1_le # edge passes point y coord going downwards
dv = numpy.roll(verts, -1, axis=0) - verts
is_left = (dv[..., 0] * xydiff[..., 1] # >0 if left of dv, <0 if right, 0 if on the line
- dv[..., 1] * xydiff[..., 0])
winding_number = ((upward & (is_left > 0)).sum(axis=0)
- (downward & (is_left < 0)).sum(axis=0))
nontrivial_inside = winding_number != 0 # filter nontrivial points based on winding number
if include_boundary:
nontrivial_inside[(is_left == 0).any(axis=0)] = True # check if point lies on any edge
inside = nontrivial.copy()
inside[nontrivial] = nontrivial_inside
return inside
def intersects(poly_a: ArrayLike, poly_b: ArrayLike) -> bool:
"""
Check if two polygons overlap and/or touch.
Args:
poly_a: List of vertices, implicitly closed: `[[x0, y0], [x1, y1], ...]`
poly_b: List of vertices, implicitly closed: `[[x0, y0], [x1, y1], ...]`
Returns:
`True` if the polygons overlap and/or touch.
"""
poly_a = numpy.array(poly_a, copy=False)
poly_b = numpy.array(poly_b, copy=False)
# Check bounding boxes
min_a = poly_a.min(axis=0)
min_b = poly_b.min(axis=0)
max_a = poly_a.max(axis=0)
max_b = poly_b.max(axis=0)
if (min_a > max_b).any() or (min_b > max_a).any():
return False
#TODO: Check against sorted coords?
#Check if edges intersect
if poly_edges_intersect(poly_a, poly_b):
return True
# Check if either polygon contains the other
if poly_contains_points(poly_b, poly_a).any():
return True
if poly_contains_points(poly_a, poly_b).any():
return True
return False
def poly_edges_intersect(
poly_a: NDArray[numpy.float64],
poly_b: NDArray[numpy.float64],
) -> NDArray[numpy.int_]:
"""
Check if the edges of two polygons intersect.
Args:
poly_a: NDArray of vertices, implicitly closed: `[[x0, y0], [x1, y1], ...]`
poly_b: NDArray of vertices, implicitly closed: `[[x0, y0], [x1, y1], ...]`
Returns:
`True` if the polygons' edges intersect.
"""
a_next = numpy.roll(poly_a, -1, axis=0)
b_next = numpy.roll(poly_b, -1, axis=0)
# Lists of initial/final coordinates for polygon segments
xi1 = poly_a[:, 0, None]
yi1 = poly_a[:, 1, None]
xf1 = a_next[:, 0, None]
yf1 = a_next[:, 1, None]
xi2 = poly_b[None, :, 0]
yi2 = poly_b[None, :, 1]
xf2 = b_next[None, :, 0]
yf2 = b_next[None, :, 1]
# Perform calculation
dxi = xi1 - xi2
dyi = yi1 - yi2
dx1 = xf1 - xi1
dx2 = xf2 - xi2
dy1 = yf1 - yi1
dy2 = yf2 - yi2
numerator_a = dx2 * dyi - dy2 * dxi
numerator_b = dx1 * dyi - dy1 * dxi
denominator = dy2 * dx1 - dx2 * dy1
# Avoid warnings since we may multiply eg. NaN*False
with numpy.errstate(invalid='ignore', divide='ignore'):
u_a = numerator_a / denominator
u_b = numerator_b / denominator
# Find the adjacency matrix
adjacency = numpy.logical_and.reduce((u_a >= 0, u_a <= 1, u_b >= 0, u_b <= 1))
return adjacency.any()

View File

204
snarled/trace.py Normal file
View File

@ -0,0 +1,204 @@
from typing import Sequence, Callable
import logging
from collections import Counter
from dataclasses import dataclass
from itertools import chain
from klayout import db
from .types import lnum_t, layer_t
logger = logging.getLogger(__name__)
def get_topcell(
layout: db.Layout,
name: str | None = None,
) -> db.Cell:
if name is None:
return layout.top_cell()
else:
ind = layout.cell_by_name(name)
return layout.cell(ind)
def write_net_layout(
l2n: db.LayoutToNetlist,
filepath: str,
layers: Sequence[lnum_t],
) -> None:
layout = db.Layout()
top = layout.create_cell('top')
lmap = {layout.layer(*layer) for layer in layers}
l2n.build_all_nets(l2n.cell_mapping_into(ly, top), ly, lmap, 'net_', 'prop_', l2n.BNH_Flatten, 'circuit_')
layout.write(filepath)
def merge_labels_from(
filepath: str,
into_layout: db.Layout,
lnum_map: dict[lnum_t, lnum_t],
topcell: str | None = None,
) -> None:
layout = db.Layout()
lm = layout.read(filepath)
topcell_obj = get_topcell(layout, topcell)
for labels_layer, conductor_layer in lnum_map:
layer_ind_src = layout.layer(*labels_layer)
layer_ind_dst = into_layout.layer(*conductor_layer)
shapes_dst = topcell_obj.shapes(layer_ind_dst)
shapes_src = topcell_obj.shapes(layer_ind_src)
for shape in shapes_dst.each():
new_shape = shapes_dst.insert(shape)
shapes_dst.replace_prop_id(new_shape, 0) # clear shape properties
@dataclass
class TraceResult:
shorts: list[str]
opens: list[str]
nets: list[set[str]]
def trace_layout(
filepath: str,
connectivity: list[layer_t, layer_t | None, layer_t],
layer_map: dict[str, lnum_t] | None = None,
topcell: str | None = None,
*,
labels_map: dict[layer_t, layer_t] = {},
lfile_path: str | None = None,
lfile_map: dict[layer_t, layer_t] | None = None,
lfile_layer_map: dict[str, lnum_t] | None = None,
lfile_topcell: str | None = None,
output_path: str | None = None,
parse_label: Callable[[str], str] | None = None,
) -> TraceResult:
if layer_map is None:
layer_map = {}
if parse_label is None:
def parse_label(label: str) -> str:
return label
layout = db.Layout()
lm = layout.read(filepath)
topcell_obj = get_topcell(layout, topcell)
# Merge labels from a separate layout if asked
if lfile_path:
if not lfile_map:
raise Exception('Asked to load labels from a separate file, but no '
'label layers were specified in lfile_map')
if lfile_layer_map is None:
lfile_layer_map = layer_map
lnum_map = {}
for ltext, lshape in lfile_map.items():
if isinstance(ltext, str):
ltext = lfile_layer_map[ltext]
if isinstance(lshape, str):
lshape = layer_map[lshape]
lnum_map[ltext] = lshape
merge_labels_from(lfile_path, layout, lnum_map, lfile_topcell)
#
# Build a netlist from the layout
#
l2n = db.LayoutToNetlist(db.RecursiveShapeIterator(layout, topcell_obj, []))
#l2n.include_floating_subcircuits = True
# Create l2n polygon layers
layer2polys = {}
for layer in set(chain(*connectivity)):
if isinstance(layer, str):
layer = layer_map[layer]
klayer = layout.layer(*layer)
layer2polys[layer] = l2n.make_polygon_layer(klayer)
# Create l2n text layers
layer2texts = {}
for layer in labels_map.keys():
if isinstance(layer, str):
layer = layer_map[layer]
klayer = layout.layer(*layer)
texts = l2n.make_text_layer(klayer)
texts.flatten()
layer2texts[layer] = texts
# Connect each layer to itself
for name, polys in layer2polys.items():
logger.info(f'Adding layer {name}')
l2n.connect(polys)
# Connect layers, optionally with vias
for top, via, bot in connectivity:
if isinstance(top, str):
top = layer_map[top]
if isinstance(via, str):
via = layer_map[via]
if isinstance(top, str):
bot = layer_map[bot]
if via is None:
l2n.connect(layer2polys[top], layer2polys[bot])
else:
l2n.connect(layer2polys[top], layer2polys[via])
l2n.connect(layer2polys[bot], layer2polys[via])
# Label nets
for label_layer, metal_layer in labels_map.items():
if isinstance(label_layer, str):
label_layer = layer_map[label_layer]
if isinstance(metal_layer, str):
metal_layer = layer_map[metal_layer]
l2n.connect(layer2polys[metal_layer], layer2texts[label_layer])
# Get netlist
nle = l2n.extract_netlist()
nl = l2n.netlist()
nl.make_top_level_pins()
if output_path:
write_net_layout(l2n, output_path, layer2polys.keys())
#
# Analyze traced nets
#
top_circuits = [cc for cc, _ in zip(nl.each_circuit_top_down(), range(nl.top_circuit_count()))]
# Nets with more than one label get their labels joined with a comma
nets = [
{parse_label(ll) for ll in nn.name.split(',')}
for cc in top_circuits
for nn in cc.each_net()
if nn.name
]
nets2 = [
nn.name
for cc in top_circuits
for nn in cc.each_net()
]
print(nets2)
# Shorts contain more than one label
shorts = [net for net in nets if len(net) > 1]
# Check number of times each label appears
net_occurences = Counter(chain.from_iterable(nets))
# If the same label appears on more than one net, warn about an open
opens = [
(nn, count)
for nn, count in net_occurences.items()
if count > 1
]
return TraceResult(shorts=shorts, opens=opens, nets=nets)

View File

@ -1,165 +0,0 @@
from typing import List, Set, ClassVar, Optional, Dict
from collections import defaultdict
from dataclasses import dataclass
from .types import layer_t, contour_t
class NetName:
"""
Basically just a uniquely-sortable `Optional[str]`.
A `name` of `None` indicates that the net is anonymous.
The `subname` is used to track multiple same-named nets, to allow testing for opens.
"""
name: Optional[str]
subname: int
count: ClassVar[defaultdict[Optional[str], int]] = defaultdict(int)
""" Counter for how many classes have been instantiated with each name """
def __init__(self, name: Optional[str] = None) -> None:
self.name = name
self.subname = self.count[name]
NetName.count[name] += 1
def __lt__(self, other: 'NetName') -> bool:
if self.name == other.name:
return self.subname < other.subname
elif self.name is None:
return False
elif other.name is None:
return True
else:
return self.name < other.name
def __repr__(self) -> str:
if self.name is not None:
name = self.name
else:
name = '(None)'
if NetName.count[self.name] == 1:
return name
else:
return f'{name}__{self.subname}'
class NetsInfo:
"""
Container for describing all nets and keeping track of the "canonical" name for each
net. Nets which are known to be shorted together should be `merge`d together,
combining their geometry under the "canonical" name and adding the other name as an alias.
"""
nets: defaultdict[NetName, defaultdict[layer_t, List]]
"""
Contains all polygons for all nets, in the format
`{net_name: {layer: [poly0, poly1, ...]}}`
Polygons are usually stored in pyclipper-friendly coordinates, but may be either `PyPolyNode`s
or simple lists of coordinates (oriented boundaries).
"""
net_aliases: Dict[NetName, NetName]
"""
A mapping from alias to underlying name.
Note that the underlying name may itself be an alias.
`resolve_name` can be used to simplify lookup
"""
def __init__(self) -> None:
self.nets = defaultdict(lambda: defaultdict(list))
self.net_aliases = {}
def resolve_name(self, net_name: NetName) -> NetName:
"""
Find the canonical name (as used in `self.nets`) for any NetName.
Args:
net_name: The name of the net to look up. May be an alias.
Returns:
The canonical name for the net.
"""
while net_name in self.net_aliases:
net_name = self.net_aliases[net_name]
return net_name
def merge(self, net_a: NetName, net_b: NetName) -> None:
"""
Combine two nets into one.
Usually used when it is discovered that two nets are shorted.
The name that is preserved is based on the sort order of `NetName`s,
which favors non-anonymous, lexicograpically small names.
Args:
net_a: A net to merge
net_b: The other net to merge
"""
net_a = self.resolve_name(net_a)
net_b = self.resolve_name(net_b)
if net_a is net_b:
return
# Always keep named nets if the other is anonymous
keep_net, old_net = sorted((net_a, net_b))
#logger.info(f'merging {old_net} into {keep_net}')
self.net_aliases[old_net] = keep_net
if old_net in self.nets:
for layer in self.nets[old_net]:
self.nets[keep_net][layer] += self.nets[old_net][layer]
del self.nets[old_net]
def prune(self, layer: layer_t) -> None:
"""
Delete all geometry for the given layer.
Args:
layer: The layer to "forget"
"""
for net in self.nets.values():
if layer in net:
del net[layer]
def get_shorted_nets(self) -> List[Set[NetName]]:
"""
List groups of non-anonymous nets which were merged.
Returns:
A list of sets of shorted nets.
"""
shorts = defaultdict(list)
for kk in self.net_aliases:
if kk.name is None:
continue
base_name = self.resolve_name(kk)
assert(base_name.name is not None)
shorts[base_name].append(kk)
shorted_sets = [set([kk] + others)
for kk, others in shorts.items()]
return shorted_sets
def get_open_nets(self) -> defaultdict[str, List[NetName]]:
"""
List groups of same-named nets which were *not* merged.
Returns:
A list of sets of same-named, non-shorted nets.
"""
opens = defaultdict(list)
seen_names = {}
for kk in self.nets:
if kk.name is None:
continue
if kk.name in seen_names:
if kk.name not in opens:
opens[kk.name].append(seen_names[kk.name])
opens[kk.name].append(kk)
else:
seen_names[kk.name] = kk
return opens

View File

@ -1,5 +1,3 @@
from typing import Union, Tuple, List, Sequence, Optional, Hashable
layer_t = Hashable
contour_t = List[Tuple[int, int]]
connectivity_t = Sequence[Tuple[layer_t, Optional[layer_t], layer_t]]
lnum_t = tuple[int, int]
layer_t = lnum_t | str

View File

@ -1,28 +1,173 @@
import logging
from .types import layer_t
logger = logging.getLogger(__name__)
def read_layermap(path: str) -> dict[str, tuple[int, int]]:
"""
Some utility code that gets reused
Read a klayout-compatible layermap file.
Only the simplest format is supported:
layer/dtype:layer_name
Empty lines are ignored.
Args:
path: filepath for the input file
Returns:
Dict of {name: (layer, dtype)}
"""
from typing import Set, Tuple
with open(path, 'rt') as ff:
lines = ff.readlines()
from .types import connectivity_t, layer_t
layer_map = {}
for nn, line in enumerate(lines):
line = line.strip()
if not line:
continue
for cc in '*-()':
if cc in line:
raise Exception(f'Failed to read layermap on line {nn} due to special character "{cc}"')
for cc in ':/':
if cc not in line:
raise Exception(f'Failed to read layermap on line {nn}; missing "{cc}"')
try:
layer_part, name = line.split(':')
layer_nums = str2lnum(layer_part)
except Exception as err:
logger.error(f'Layer map read failed on line {nn}')
raise err
layer_map[name.strip()] = (layer, dtype)
return layer_map
def connectivity2layers(
connectivity: connectivity_t,
) -> Tuple[Set[layer_t], Set[layer_t]]:
def read_connectivity(path: str) -> list[tuple[layer_t, layer_t | None, layer_t]]:
"""
Extract the set of all metal layers and the set of all via layers
from the connectivity description.
Read a connectivity spec file, which takes the form
conductor0, via01, conductor1
conductor1, via12, conductor2
conductor0, via02, conductor2
...
conductorX, conductorY
where each comma-separated entry is a layer name or numerical layer/dtype
deisgnation (e.g. 123/45). Empty lines are ignored. Lines with only 2 entries
are directly connected without needing a separate via layer.
Args:
path: filepath for the input file
Returns:
List of layer spec tuples (A, viaAB, B); the middle entry will be None
if no via is given.
"""
metal_layers = set()
via_layers = set()
for top, via, bot in connectivity:
metal_layers.add(top)
metal_layers.add(bot)
if via is not None:
via_layers.add(via)
with open(path, 'rt') as ff:
lines = ff.readlines()
both = metal_layers.intersection(via_layers)
if both:
raise Exception(f'The following layers are both vias and metals!? {both}')
connections = []
for nn, line in enumerate(lines):
line = line.strip()
if not line:
continue
return metal_layers, via_layers
parts = line.split(',')
if len(parts) not in (2, 3):
raise Exception(f'Too many commas in connectivity spec on line {nn}')
layers = []
for part in enumerate(parts):
if '/' in part:
try:
layer = str2lnum(layer_part)
except Exception as err:
logger.error(f'Connectivity spec read failed on line {nn}')
raise err
else:
layer = part.strip()
if not layer:
raise Exception(f'Empty layer in connectivity spec on line {nn}')
layers.append(layer)
if len(layers) == 2:
connections.append((layers[0], None, layers[1]))
else:
connections.append(tuple(layers))
return connections
def read_remap(path: str) -> dict[layer_t, layer_t]:
"""
Read a layer remap spec file, which takes the form
old_layer1 : new_layer1
old_layer2 : new_layer2
...
where each layer entry is a layer name or numerical layer/dtype
designation (e.g. 123/45).
Empty lines are ignored.
Args:
path: filepath for the input file
Returns:
Dict mapping from left (old) layers to right (new) layers
"""
with open(path, 'rt') as ff:
lines = ff.readlines()
remap = {}
for nn, line in enumerate(lines):
line = line.strip()
if not line:
continue
parts = line.split(':')
if len(parts) != 2:
raise Exception(f'Too many commas in layer remap spec on line {nn}')
layers = []
for part in enumerate(parts):
if '/' in part:
try:
layer = str2lnum(layer_part)
except Exception as err:
logger.error(f'Layer remap spec read failed on line {nn}')
raise err
else:
layer = part.strip()
if not layer:
raise Exception(f'Empty layer in layer remap spec on line {nn}')
layers.append(layer)
remap[layers[0]] = layers[1]
return remap
def str2lnum(string: str) -> tuple[int, int]:
"""
Parse a '123/45'-style layer/dtype spec string.
Args:
string: String specifying the layer/dtype
Returns:
(layer, dtype)
"""
layer_str, dtype_str = string.split('/')
layer = int(layer_str)
dtype = int(dtype_str)
return (layer, dtype)