diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..0042015 --- /dev/null +++ b/.flake8 @@ -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, diff --git a/examples/check.py b/examples/check.py index 802610a..5d9f5a5 100644 --- a/examples/check.py +++ b/examples/check.py @@ -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)) diff --git a/examples/connectivity.txt b/examples/connectivity.txt new file mode 100644 index 0000000..523f529 --- /dev/null +++ b/examples/connectivity.txt @@ -0,0 +1,3 @@ +M1, V12, M2 +M1, V13, M3 +M2, V23, M3 diff --git a/examples/layermap.txt b/examples/layermap.txt new file mode 100644 index 0000000..576385b --- /dev/null +++ b/examples/layermap.txt @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..65849ae --- /dev/null +++ b/examples/run.sh @@ -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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..07bf7eb --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/setup.py b/setup.py deleted file mode 100644 index 817bef6..0000000 --- a/setup.py +++ /dev/null @@ -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', - ], - ) - diff --git a/snarled/VERSION.py b/snarled/VERSION.py deleted file mode 100644 index e4f476e..0000000 --- a/snarled/VERSION.py +++ /dev/null @@ -1,4 +0,0 @@ -""" 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 1036877..3dc9d8e 100644 --- a/snarled/__init__.py +++ b/snarled/__init__.py @@ -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' diff --git a/snarled/__main__.py b/snarled/__main__.py new file mode 100644 index 0000000..7a93ffb --- /dev/null +++ b/snarled/__main__.py @@ -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)) diff --git a/snarled/clipper.py b/snarled/clipper.py deleted file mode 100644 index 8c01e2e..0000000 --- a/snarled/clipper.py +++ /dev/null @@ -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 diff --git a/snarled/interfaces/__init__.py b/snarled/interfaces/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/snarled/interfaces/masque.py b/snarled/interfaces/masque.py deleted file mode 100644 index 5cca9f9..0000000 --- a/snarled/interfaces/masque.py +++ /dev/null @@ -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 diff --git a/snarled/main.py b/snarled/main.py deleted file mode 100644 index 485da04..0000000 --- a/snarled/main.py +++ /dev/null @@ -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 diff --git a/snarled/poly.py b/snarled/poly.py deleted file mode 100644 index 223905f..0000000 --- a/snarled/poly.py +++ /dev/null @@ -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() diff --git a/snarled/py.typed b/snarled/py.typed deleted file mode 100644 index e69de29..0000000 diff --git a/snarled/trace.py b/snarled/trace.py new file mode 100644 index 0000000..00156e7 --- /dev/null +++ b/snarled/trace.py @@ -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) diff --git a/snarled/tracker.py b/snarled/tracker.py deleted file mode 100644 index 55439e1..0000000 --- a/snarled/tracker.py +++ /dev/null @@ -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 diff --git a/snarled/types.py b/snarled/types.py index ec2b4c6..8cf9001 100644 --- a/snarled/types.py +++ b/snarled/types.py @@ -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 diff --git a/snarled/utils.py b/snarled/utils.py index 6ca3567..5671021 100644 --- a/snarled/utils.py +++ b/snarled/utils.py @@ -1,28 +1,173 @@ -""" -Some utility code that gets reused -""" -from typing import Set, Tuple - -from .types import connectivity_t, layer_t +import logging +from .types import layer_t -def connectivity2layers( - connectivity: connectivity_t, - ) -> Tuple[Set[layer_t], Set[layer_t]]: +logger = logging.getLogger(__name__) + + +def read_layermap(path: str) -> dict[str, tuple[int, int]]: """ - Extract the set of all metal layers and the set of all via layers - from the connectivity description. + 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)} """ - 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}') + layer_map = {} + for nn, line in enumerate(lines): + line = line.strip() + if not line: + continue - return metal_layers, via_layers + 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 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 = [] + for nn, line in enumerate(lines): + line = line.strip() + if not line: + continue + + 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)