diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 0042015..0000000 --- a/.flake8 +++ /dev/null @@ -1,29 +0,0 @@ -[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, diff --git a/README.md b/README.md index 7fd69d7..8789b33 100644 --- a/README.md +++ b/README.md @@ -10,105 +10,107 @@ has deprived the man of both a schematic and a better connectivity tool. - [Source repository](https://mpxd.net/code/jan/snarled) - [PyPI](https://pypi.org/project/snarled) -- [Github mirror](https://github.com/anewusername/snarled) ## Installation Requirements: -* python >= 3.10 (written and tested with 3.11) +* python >= 3.9 (written and tested with 3.10) * numpy -* klayout (python package only) +* pyclipper Install with pip: ```bash -pip install snarled +pip3 install snarled ``` Alternatively, install from git ```bash -pip install git+https://mpxd.net/code/jan/snarled.git@release +pip3 install git+https://mpxd.net/code/jan/snarled.git@release ``` ## Example -See `examples/check.py` (python interface) or `examples/run.sh` (command-line interface). +See `examples/check.py`. Note that the example uses `masque` to load data. -Command line: -```bash -snarled connectivity.oas connectivity.txt -m layermap.txt -``` - -Python interface: ```python3 from pprint import pformat -import logging - +from masque.file import gdsii, oasis import snarled -from snarled.types import layer_t +import snarled.interfaces.masque - -logging.basicConfig() -logging.getLogger('snarled').setLevel(logging.INFO) - - -connectivity = [ - ((1, 0), (1, 2), (2, 0)), # M1 to M2 (via V12) - ((1, 0), (1, 3), (3, 0)), # M1 to M3 (via V13) - ((2, 0), (2, 3), (3, 0)), # M2 to M3 (via V23) - ] - -labels_map: dict[layer_t, layer_t] = { - (1, 0): (1, 0), - (2, 0): (2, 0), - (3, 0): (3, 0), +# Layer definitions +connectivity = { + ((1, 0), (1, 2), (2, 0)), #M1 to M2 (via V12) + ((1, 0), (1, 3), (3, 0)), #M1 to M3 (via V13) + ((2, 0), (2, 3), (3, 0)), #M2 to M3 (via V23) } -filename = 'connectivity.oas' -nets = snarled.trace_layout(filename, connectivity, topcell='top', labels_map=labels_map) -result = snarled.TraceAnalysis(nets) +cells, props = oasis.readfile('connectivity.oas') +topcell = cells['top'] -print('\n') -print(result) +polys, labels = snarled.interfaces.masque.read_cell(topcell, connectivity) +nets_info = snarled.trace_connectivity(polys, labels, connectivity) + +print('\nFinal nets:') +print([kk for kk in nets_info.nets 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()))) ``` this prints the following: ``` -INFO:snarled.trace:Adding layer (3, 0) -INFO:snarled.trace:Adding layer (2, 3) -INFO:snarled.trace:Adding layer (1, 3) -INFO:snarled.trace:Adding layer (1, 2) -INFO:snarled.trace:Adding layer (1, 0) -INFO:snarled.trace:Adding layer (2, 0) +Nets ['SignalD', 'SignalI'] are shorted on layer (1, 0) in poly: + [[13000.0, -3000.0], + [16000.0, -3000.0], + [16000.0, -1000.0], + [13000.0, -1000.0], + [13000.0, 2000.0], + [12000.0, 2000.0], + [12000.0, -1000.0], + [11000.0, -1000.0], + [11000.0, -3000.0], + [12000.0, -3000.0], + [12000.0, -8000.0], + [13000.0, -8000.0]] +Nets ['SignalK', 'SignalK'] are shorted on layer (1, 0) in poly: + [[18500.0, -8500.0], [28200.0, -8500.0], [28200.0, 1000.0], [18500.0, 1000.0]] +Nets ['SignalC', 'SignalC'] are shorted on layer (1, 0) in poly: + [[10200.0, 0.0], [-1100.0, 0.0], [-1100.0, -1000.0], [10200.0, -1000.0]] +Nets ['SignalG', 'SignalH'] are shorted on layer (1, 0) in poly: + [[10100.0, -2000.0], [5100.0, -2000.0], [5100.0, -3000.0], [10100.0, -3000.0]] +Final nets: +[SignalA, SignalC__0, SignalE, SignalG, SignalK__0, SignalK__2, SignalL] -Trace analysis -============= -Nets -(groups of electrically connected labels) - SignalA,SignalB - SignalC,SignalD,SignalI - SignalE,SignalF - SignalG,SignalH - SignalK - SignalK - SignalL +Shorted net sets: +(SignalC__0,SignalC__1,SignalD,SignalI) +(SignalK__0,SignalK__1) +(SignalG,SignalH) +(SignalA,SignalB) +(SignalE,SignalF) -Opens -(2+ nets containing the same name) - SignalK : 2 nets - -Shorts -(2+ unique names for the same net) - SignalA,SignalB - SignalC,SignalD,SignalI - SignalE,SignalF - SignalG,SignalH -============= +Open nets: +{'SignalK': [SignalK__0, SignalK__2]} ``` ## Code organization -- The primary functionality is in `trace`; specifically `trace.trace_layout()`. -- `main` provides a command-line interface, supported by the functions in `utils`. +- 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. + +## Caveats + +This package is slow, dumb, and the code is ugly. There's only a basic test case. + +If you know what you're doing, you could probably do a much better job of it. + +...but you *have* heard of it :) + diff --git a/examples/check.py b/examples/check.py index 4e677b7..802610a 100644 --- a/examples/check.py +++ b/examples/check.py @@ -1,33 +1,40 @@ """ -Example code for checking connectivity in a layout by using `snarled` +Example code for checking connectivity in a layout by using +`snarled` and `masque`. """ +from pprint import pformat import logging +from masque.file import gdsii, oasis + import snarled -from snarled.types import layer_t +import snarled.interfaces.masque logging.basicConfig() logging.getLogger('snarled').setLevel(logging.INFO) -# How are the conductors connected to each other? + connectivity = [ - ((1, 0), (1, 2), (2, 0)), # M1 to M2 (via V12) - ((1, 0), (1, 3), (3, 0)), # M1 to M3 (via V13) - ((2, 0), (2, 3), (3, 0)), # M2 to M3 (via V23) + ((1, 0), (1, 2), (2, 0)), #M1 to M2 (via V12) + ((1, 0), (1, 3), (3, 0)), #M1 to M3 (via V13) + ((2, 0), (2, 3), (3, 0)), #M2 to M3 (via V23) ] -# What labels should be loaded, and which geometry layers should they apply to? -labels_map: dict[layer_t, layer_t] = { - (1, 0): (1, 0), - (2, 0): (2, 0), - (3, 0): (3, 0), - } -filename = 'connectivity.oas' +#cells, props = gdsii.readfile('connectivity.gds') +cells, props = oasis.readfile('connectivity.oas') +topcell = cells['top'] -nets = snarled.trace_layout(filename, connectivity, topcell='top', labels_map=labels_map) -result = snarled.TraceAnalysis(nets) +get_layer = snarled.interfaces.masque.prepare_cell(topcell) +nets_info = snarled.trace_connectivity(get_layer, connectivity) -print('\n') -print(result) +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()))) diff --git a/examples/connectivity.oas b/examples/connectivity.oas index dd7da8b..865bf31 100644 Binary files a/examples/connectivity.oas and b/examples/connectivity.oas differ diff --git a/examples/connectivity.txt b/examples/connectivity.txt deleted file mode 100644 index 523f529..0000000 --- a/examples/connectivity.txt +++ /dev/null @@ -1,3 +0,0 @@ -M1, V12, M2 -M1, V13, M3 -M2, V23, M3 diff --git a/examples/layermap.txt b/examples/layermap.txt deleted file mode 100644 index 576385b..0000000 --- a/examples/layermap.txt +++ /dev/null @@ -1,6 +0,0 @@ -1/0:M1 -2/0:M2 -3/0:M3 -1/2:V12 -1/3:V13 -2/3:V23 diff --git a/examples/run.sh b/examples/run.sh deleted file mode 100755 index 283dc25..0000000 --- a/examples/run.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -cd $(dirname -- "$0") # cd to this script's parent directory - -snarled connectivity.oas connectivity.txt -m layermap.txt diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index 868c432..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,93 +0,0 @@ -[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.11" -dynamic = ["version"] -dependencies = [ - "klayout~=0.29", - ] - - -[tool.hatch.version] -path = "snarled/__init__.py" - -[project.scripts] -snarled = "snarled.main:main" - - -[tool.ruff] -exclude = [ - ".git", - "dist", - ] -line-length = 145 -indent-width = 4 -lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" -lint.select = [ - "NPY", "E", "F", "W", "B", "ANN", "UP", "SLOT", "SIM", "LOG", - "C4", "ISC", "PIE", "PT", "RET", "TCH", "PTH", "INT", - "ARG", "PL", "R", "TRY", - "G010", "G101", "G201", "G202", - "Q002", "Q003", "Q004", - ] -lint.ignore = [ - #"ANN001", # No annotation - "ANN002", # *args - "ANN003", # **kwargs - "ANN401", # Any - "ANN101", # self: Self - "SIM108", # single-line if / else assignment - "RET504", # x=y+z; return x - "PIE790", # unnecessary pass - "ISC003", # non-implicit string concatenation - "C408", # dict(x=y) instead of {'x': y} - "PLR09", # Too many xxx - "PLR2004", # magic number - "PLC0414", # import x as x - "TRY003", # Long exception message - "PTH123", # open() - "UP015", # open(..., 'rt') - "PLW2901", # overwriting loop var - ] - diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..817bef6 --- /dev/null +++ b/setup.py @@ -0,0 +1,63 @@ +#!/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', + ], + ) + diff --git a/snarled/VERSION.py b/snarled/VERSION.py new file mode 100644 index 0000000..e4f476e --- /dev/null +++ b/snarled/VERSION.py @@ -0,0 +1,4 @@ +""" VERSION defintion. THIS FILE IS MANUALLY PARSED BY setup.py and REQUIRES A SPECIFIC FORMAT """ +__version__ = ''' +0.6 +'''.strip() diff --git a/snarled/__init__.py b/snarled/__init__.py index 86944c6..1036877 100644 --- a/snarled/__init__.py +++ b/snarled/__init__.py @@ -7,15 +7,17 @@ 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 a schematic and a better connectivity tool. +has deprived the man of both a schematic and a better connectivity tool. -The main functionality is in `trace`. -`__main__.py` details the command-line interface. +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. """ -from .trace import ( - trace_layout as trace_layout, - TraceAnalysis as TraceAnalysis, - ) +from .main import trace_connectivity, trace_connectivity_preloaded +from .tracker import NetsInfo, NetName +from . import interfaces + __author__ = 'Jan Petykiewicz' -__version__ = '1.0' + +from .VERSION import __version__ diff --git a/snarled/__main__.py b/snarled/__main__.py deleted file mode 100644 index 25b2b3d..0000000 --- a/snarled/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ - -from .main import main -main() diff --git a/snarled/clipper.py b/snarled/clipper.py new file mode 100644 index 0000000..8c01e2e --- /dev/null +++ b/snarled/clipper.py @@ -0,0 +1,64 @@ +""" +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 diff --git a/snarled/interfaces/__init__.py b/snarled/interfaces/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/snarled/interfaces/masque.py b/snarled/interfaces/masque.py new file mode 100644 index 0000000..5cca9f9 --- /dev/null +++ b/snarled/interfaces/masque.py @@ -0,0 +1,127 @@ +""" +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 diff --git a/snarled/main.py b/snarled/main.py index 61c99d6..485da04 100644 --- a/snarled/main.py +++ b/snarled/main.py @@ -1,80 +1,369 @@ -from typing import Any -import argparse +""" +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 -from . import utils -from .trace import trace_layout, TraceAnalysis +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 -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -def main() -> int: - parser = argparse.ArgumentParser( - prog='snarled', - description='layout connectivity checker', - ) +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. - 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') + 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). - parser.add_argument('-l', '--lfile-path') - parser.add_argument('-r', '--lremap') - parser.add_argument('-n', '--llayermap') - parser.add_argument('-s', '--ltop') + 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. - parser.add_argument('-o', '--output') - parser.add_argument('-u', '--raw-label-names', action='store_true') + 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] - args = parser.parse_args() + return trace_connectivity(get_layer, connectivity, clipper_scale_factor) - filepath = args.file_path - connectivity = utils.read_connectivity(args.connectivity_path) - kwargs: dict[str, Any] = {} +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. - if args.layermap: - kwargs['layer_map'] = utils.read_layermap(args.layermap) + 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). - if args.top: - kwargs['topcell'] = args.top + 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. - if args.labels_remap: - kwargs['labels_remap'] = utils.read_remap(args.labels_remap) + Args: + get_layer: When called, `polys, labels = get_layer(layer)` should return the geometry and labels + on that layer. Returns - if args.lfile_path: - assert args.lremap - kwargs['lfile_path'] = args.lfile_path - kwargs['lfile_map'] = utils.read_remap(args.lremap) + polys, A list of polygons (Nx2 arrays of vertices) on the layer. The structure looks like + `[poly0, poly1, ..., [(x0, y0), (x1, y1), ...]]` - if args.llayermap: - kwargs['lfile_layermap'] = utils.read_layermap(args.llayermap) + 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), ...]` - if args.ltop: - kwargs['lfile_topcell'] = args.ltop + connectivity: A sequence of 3-tuples specifying the electrical connectivity between layers. - if args.output: - kwargs['output_path'] = args.output + 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. - if not args.raw_label_names: - from .utils import strip_underscored_label as parse_label + `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: - def parse_label(string: str) -> str: - return string + return [] - nets = trace_layout( - filepath=filepath, - connectivity=connectivity, - **kwargs, - ) - parsed_nets = [{parse_label(ll) for ll in net} for net in nets] - result = TraceAnalysis(parsed_nets) - print('\n') - print(result) +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). - return 0 + 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 diff --git a/snarled/poly.py b/snarled/poly.py new file mode 100644 index 0000000..223905f --- /dev/null +++ b/snarled/poly.py @@ -0,0 +1,157 @@ +""" +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() diff --git a/snarled/py.typed b/snarled/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/snarled/trace.py b/snarled/trace.py deleted file mode 100644 index f73d3e1..0000000 --- a/snarled/trace.py +++ /dev/null @@ -1,315 +0,0 @@ -from collections.abc import Sequence, Iterable -import logging -from collections import Counter -from itertools import chain - -from klayout import db -from .types import lnum_t, layer_t -from .utils import SnarledError - - -logger = logging.getLogger(__name__) - - -class TraceAnalysis: - """ - Short/Open analysis for a list of nets - """ - - nets: list[set[str]] - """ List of nets (connected sets of labels) """ - - opens: dict[str, int] - """ Labels which appear on 2+ disconnected nets, and the number of nets they touch """ - - shorts: list[set[str]] - """ Nets containing more than one unique label """ - - def __init__( - self, - nets: Sequence[Iterable[str]], - ) -> None: - """ - Args: - nets: Sequence of nets. Each net is a sequence of labels - which were found to be electrically connected. - """ - - setnets = [set(net) for net in nets] - - # Shorts contain more than one label - shorts = [net for net in setnets if len(net) > 1] - - # Check number of times each label appears - net_occurences = Counter(chain.from_iterable(setnets)) - - # Opens are where the same label appears on more than one net - opens = { - nn: count - for nn, count in net_occurences.items() - if count > 1 - } - - self.nets = setnets - self.shorts = shorts - self.opens = opens - - def __repr__(self) -> str: - def format_net(net: Iterable[str]) -> str: - names = [f"'{name}'" if any(cc in name for cc in ' \t\n') else name for name in sorted(net)] - return ','.join(names) - - def sort_nets(nets: Sequence[Iterable[str]]) -> list[Iterable[str]]: - return sorted(nets, key=lambda net: ','.join(sorted(net))) - - ss = 'Trace analysis' - ss += '\n=============' - - ss += '\nNets' - ss += '\n(groups of electrically connected labels)\n' - for net in sort_nets(self.nets): - ss += '\t' + format_net(net) + '\n' - if not self.nets: - ss += '\t\n' - - ss += '\nOpens' - ss += '\n(2+ nets containing the same name)\n' - for label, count in sorted(self.opens.items()): - ss += f'\t{label} : {count} nets\n' - if not self.opens: - ss += '\t\n' - - ss += '\nShorts' - ss += '\n(2+ unique names for the same net)\n' - for net in sort_nets(self.shorts): - ss += '\t' + format_net(net) + '\n' - if not self.shorts: - ss += '\t\n' - - ss += '=============\n' - return ss - - -def trace_layout( - filepath: str, - connectivity: Sequence[tuple[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] | None = None, - 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, - ) -> list[set[str]]: - """ - Trace a layout to identify labeled nets. - - To label a net, place a text label anywhere touching the net. - Labels may be mapped from a different layer, or even a different - layout file altogether. - Note: Labels must not contain commas (,)!! - - Args: - filepath: Path to the primary layout, containing the conductor geometry - (and optionally also the labels) - connectivity: List of (conductor1, via12, conductor2) tuples, - which indicate that the specified layers are electrically connected - (conductor1 to via12 and via12 to conductor2). The middle (via) layer - may be `None`, in which case the outer layers are directly connected - at any overlap (conductor1 to conductor2). - layer_map: {layer_name: (layer_num, dtype_num)} translation table. - Should contain any strings present in `connectivity` and `labels_map`. - Default is an empty dict. - topcell: Cell name of the topcell. If `None`, it is automatically chosen. - labels_map: {label_layer: metal_layer} mapping, which allows labels to - reside on a different layer from their corresponding metals. - Only labels on the provided label layers are used, so - {metal_layer: metal_layer} entries must be explicitly specified if - they are desired. - If `None`, labels on each layer in `connectivity` are used alongside - that same layer's geometry ({layer: layer} for all participating - geometry layers) - Default `None`. - lfile_path: Path to a separate file from which labels should be merged. - lfile_map: {lfile_layer: primary_layer} mapping, used when merging the - labels into the primary layout. - lfile_layer_map: {layer_name: (layer_num, dtype_num)} mapping for the - secondary (label) file. Should contain all string keys in - `lfile_map`. - `None` reuses `layer_map` (default). - lfile_topcell: Cell name for the topcell in the secondary (label) file. - `None` automatically chooses the topcell (default). - output_path: If provided, outputs the final net geometry to a layout - at the given path. Default `None`. - - Returns: - List of labeled nets, where each entry is a set of label strings which - were found on the given net. - """ - if layer_map is None: - layer_map = {} - - if labels_map is None: - labels_map = { - layer: layer - for layer in chain(*connectivity) - if layer is not None - } - - layout = db.Layout() - 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 SnarledError('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, topcell_obj, 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 layer is None: - continue - 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: - 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(bot, 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 - l2n.extract_netlist() - nl = l2n.netlist() - nl.make_top_level_pins() - - if output_path: - _write_net_layout(l2n, output_path, layer2polys) - - # - # Return merged nets - # - top_circuits = [cc for cc, _ in zip(nl.each_circuit_top_down(), range(nl.top_circuit_count()), strict=False)] - - # Nets with more than one label get their labels joined with a comma - nets = [ - set(nn.name.split(',')) - for cc in top_circuits - for nn in cc.each_net() - if nn.name - ] - return nets - - -def _get_topcell( - layout: db.Layout, - name: str | None = None, - ) -> db.Cell: - """ - Get the topcell by name or hierarchy. - - Args: - layout: Layout to get the cell from - name: If given, use the name to find the topcell; otherwise use hierarchy. - - Returns: - Cell object - """ - if name is None: - return layout.top_cell() - ind = layout.cell_by_name(name) - return layout.cell(ind) - - -def _write_net_layout( - l2n: db.LayoutToNetlist, - filepath: str, - layer2polys: dict[lnum_t, db.Region], - ) -> None: - layout = db.Layout() - top = layout.create_cell('top') - lmap = {layout.layer(*layer): polys for layer, polys in layer2polys.items()} - l2n.build_all_nets(l2n.cell_mapping_into(layout, top), layout, lmap, 'net_', 'prop_', l2n.BNH_Flatten, 'circuit_') - layout.write(filepath) - - -def _merge_labels_from( - filepath: str, - into_layout: db.Layout, - into_cell: db.Cell, - lnum_map: dict[lnum_t, lnum_t], - topcell: str | None = None, - ) -> None: - layout = db.Layout() - layout.read(filepath) - - topcell_obj = _get_topcell(layout, topcell) - - for labels_layer, conductor_layer in lnum_map.items(): - 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 = into_cell.shapes(layer_ind_src) - for shape in shapes_src.each(): - new_shape = shapes_dst.insert(shape) - shapes_dst.replace_prop_id(new_shape, 0) # clear shape properties diff --git a/snarled/tracker.py b/snarled/tracker.py new file mode 100644 index 0000000..55439e1 --- /dev/null +++ b/snarled/tracker.py @@ -0,0 +1,165 @@ +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 diff --git a/snarled/types.py b/snarled/types.py index 8cf9001..ec2b4c6 100644 --- a/snarled/types.py +++ b/snarled/types.py @@ -1,3 +1,5 @@ +from typing import Union, Tuple, List, Sequence, Optional, Hashable -lnum_t = tuple[int, int] -layer_t = lnum_t | str +layer_t = Hashable +contour_t = List[Tuple[int, int]] +connectivity_t = Sequence[Tuple[layer_t, Optional[layer_t], layer_t]] diff --git a/snarled/utils.py b/snarled/utils.py index 752d61e..6ca3567 100644 --- a/snarled/utils.py +++ b/snarled/utils.py @@ -1,198 +1,28 @@ -import logging -from .types import layer_t +""" +Some utility code that gets reused +""" +from typing import Set, Tuple + +from .types import connectivity_t, layer_t -logger = logging.getLogger(__name__) - - -class SnarledError(Exception): - pass - - -def strip_underscored_label(string: str) -> str: +def connectivity2layers( + connectivity: connectivity_t, + ) -> Tuple[Set[layer_t], Set[layer_t]]: """ - If the label ends in an underscore followed by an integer, strip - that suffix. Otherwise, just return the label. - - Args: - string: The label string - - Returns: - The label string, with the suffix removed (if one was found) + Extract the set of all metal layers and the set of all via layers + from the connectivity description. """ - try: - parts = string.split('_') - int(parts[-1]) # must succeed to continue - return '_'.join(parts[:-1]) - except Exception: - return string + 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) + both = metal_layers.intersection(via_layers) + if both: + raise Exception(f'The following layers are both vias and metals!? {both}') -def read_layermap(path: str) -> dict[str, tuple[int, int]]: - """ - 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)} - """ - with open(path, 'rt') as ff: - lines = ff.readlines() - - layer_map = {} - for nn, line in enumerate(lines): - line = line.strip() - if not line: - continue - - for cc in '*-()': - if cc in line: - raise SnarledError(f'Failed to read layermap on line {nn} due to special character "{cc}"') - - for cc in ':/': - if cc not in line: - raise SnarledError(f'Failed to read layermap on line {nn}; missing "{cc}"') - - try: - layer_part, name = line.split(':') - layer_nums = str2lnum(layer_part) - except Exception: - logger.exception(f'Layer map read failed on line {nn}') - raise - - layer_map[name.strip()] = layer_nums - - return layer_map - - -def read_connectivity(path: str) -> list[tuple[layer_t, layer_t | None, layer_t]]: - """ - 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. - """ - with open(path, 'rt') as ff: - lines = ff.readlines() - - connections: list[tuple[layer_t, layer_t | None, layer_t]] = [] - for nn, line in enumerate(lines): - line = line.strip() - if not line: - continue - - parts = line.split(',') - - if len(parts) not in (2, 3): - raise SnarledError(f'Too many commas in connectivity spec on line {nn}') - - layers = [] - for part in parts: - layer: layer_t - if '/' in part: - try: - layer = str2lnum(part) - except Exception: - logger.exception(f'Connectivity spec read failed on line {nn}') - raise - else: - layer = part.strip() - if not layer: - raise SnarledError(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((layers[0], layers[1], layers[2])) - - 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 SnarledError(f'Too many commas in layer remap spec on line {nn}') - - layers = [] - for part in parts: - layer: layer_t - if '/' in part: - try: - layer = str2lnum(part) - except Exception: - logger.exception(f'Layer remap spec read failed on line {nn}') - raise - else: - layer = part.strip() - if not layer: - raise SnarledError(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) + return metal_layers, via_layers