diff --git a/README.md b/README.md index 62b13bb..128a8c5 100644 --- a/README.md +++ b/README.md @@ -3,286 +3,49 @@ Masque is a Python module for designing lithography masks. The general idea is to implement something resembling the GDSII file-format, but -with some vectorized element types (eg. circles, not just polygons) and the ability -to output to multiple formats. +with some vectorized element types (eg. circles, not just polygons), better support for +E-beam doses, and the ability to output to multiple formats. - [Source repository](https://mpxd.net/code/jan/masque) - [PyPI](https://pypi.org/project/masque) -- [Github mirror](https://github.com/anewusername/masque) ## Installation Requirements: -* python >= 3.11 +* python >= 3.8 * numpy -* klamath (used for GDSII i/o) - -Optional requirements: -* `ezdxf` (DXF i/o): ezdxf -* `oasis` (OASIS i/o): fatamorgana -* `svg` (SVG output): svgwrite -* `visualization` (shape plotting): matplotlib -* `text` (`Text` shape): matplotlib, freetype +* klamath (used for `gdsii` i/o and library management) +* matplotlib (optional, used for `visualization` functions and `text`) +* ezdxf (optional, used for `dxf` i/o) +* fatamorgana (optional, used for `oasis` i/o) +* svgwrite (optional, used for `svg` output) +* freetype (optional, used for `text`) Install with pip: ```bash -pip install 'masque[oasis,dxf,svg,visualization,text]' +pip3 install 'masque[visualization,oasis,dxf,svg,text]' ``` -## Overview - -A layout consists of a hierarchy of `Pattern`s stored in a single `Library`. -Each `Pattern` can contain `Ref`s pointing at other patterns, `Shape`s, `Label`s, and `Port`s. - - -Library / Pattern hierarchy: -``` - +-----------------------------------------------------------------------+ - | Library | - | | - | Name: "MyChip" ...> Name: "Transistor" | - | +---------------------------+ : +---------------------------+ | - | | [Pattern] | : | [Pattern] | | - | | | : | | | - | | shapes: {...} | : | shapes: { | | - | | ports: {...} | : | "Si": [, ...] | | - | | | : | "M1": [, ...]}| | - | | refs: | : | ports: {G, S, D} | | - | | "Transistor": [Ref, Ref]|..: +---------------------------+ | - | +---------------------------+ | - | | - | # (`refs` keys resolve to Patterns within the Library) | - +-----------------------------------------------------------------------+ +Alternatively, install from git +```bash +pip3 install git+https://mpxd.net/code/jan/masque.git@release ``` - -Pattern internals: -``` - +---------------------------------------------------------------+ - | [Pattern] | - | | - | shapes: { | - | (1, 0): [Polygon, Circle, ...], # Geometry by layer | - | (2, 0): [Path, ...] | - | "M1" : [Path, ...] | - | "M2" : [Polygon, ...] | - | } | - | | - | refs: { # Key sets target name, Ref sets transform | - | "my_cell": [ | - | Ref(offset=(0,0), rotation=0), | - | Ref(offset=(10,0), rotation=R90, repetition=Grid(...)) | - | ] | - | } | - | | - | ports: { | - | "in": Port(offset=(0,0), rotation=0, ptype="M1"), | - | "out": Port(offset=(10,0), rotation=R180, ptype="wg") | - | } | - | | - +---------------------------------------------------------------+ -``` - - -`masque` departs from several "classic" GDSII paradigms: -- A `Pattern` object does not store its own name. A name is only assigned when the pattern is placed - into a `Library`, which is effectively a name->`Pattern` mapping. -- Layer info for `Shape`ss and `Label`s is not stored in the individual shape and label objects. - Instead, the layer is determined by the key for the container dict (e.g. `pattern.shapes[layer]`). - * This simplifies many common tasks: filtering `Shape`s by layer, remapping layers, and checking if - a layer is empty. - * Technically, this allows reusing the same shape or label object across multiple layers. This isn't - part of the standard workflow since a mixture of single-use and multi-use shapes could be confusing. - * This is similar to the approach used in [KLayout](https://www.klayout.de) -- `Ref` target names are also determined in the key of the container dict (e.g. `pattern.refs[target_name]`). - * This similarly simplifies filtering `Ref`s by target name, updating to a new target, and checking - if a given `Pattern` is referenced. -- `Pattern` names are set by their containing `Library` and are not stored in the `Pattern` objects. - * This guarantees that there are no duplicate pattern names within any given `Library`. - * Likewise, enumerating all the names (and all the `Pattern`s) in a `Library` is straightforward. -- Each `Ref`, `Shape`, or `Label` can be repeated multiple times by attaching a `repetition` object to it. - * This is similar to how OASIS reptitions are handled, and provides extra flexibility over the GDSII - approach of only allowing arrays through AREF (`Ref` + `repetition`). -- `Label`s do not have an orientation or presentation - * This is in line with how they are used in practice, and how they are represented in OASIS. -- Non-polygonal `Shape`s are allowed. For example, elliptical arcs are a basic shape type. - * This enables compatibility with OASIS (e.g. circles) and other formats. - * `Shape`s provide a `.to_polygons()` method for GDSII compatibility. -- Most coordinate values are stored as 64-bit floats internally. - * 1 earth radii in nanometers (6e15) is still represented without approximation (53 bit mantissa -> 2^53 > 9e15) - * Operations that would otherwise clip/round on are still represented approximately. - * Memory usage is usually dominated by other Python overhead. -- `Pattern` objects also contain `Port` information, which can be used to "snap" together - multiple sub-components by matching up the requested port offsets and rotations. - * Port rotations are defined as counter-clockwise angles from the +x axis. - * Ports point into the interior of their associated device. - * Port rotations may be `None` in the case of non-oriented ports. - * Ports have a `ptype` string which is compared in order to catch mismatched connections at build time. - * Ports can be exported into/imported from `Label`s stored directly in the layout, - editable from standard tools (e.g. KLayout). A default format is provided. - -In one important way, `masque` stays very orthodox: -References are accomplished by listing the target's name, not its `Pattern` object. - -- The main downside of this is that any operations that traverse the hierarchy require - both the `Pattern` and the `Library` which is contains its reference targets. -- This guarantees that names within a `Library` remain unique at all times. - * Since this can be tedious in cases where you don't actually care about the name of a - pattern, patterns whose names start with `SINGLE_USE_PREFIX` (default: an underscore) - may be silently renamed in order to maintain uniqueness. - See `masque.library.SINGLE_USE_PREFIX`, `masque.library._rename_patterns()`, - and `ILibrary.add()` for more details. -- Having all patterns accessible through the `Library` avoids having to perform a - tree traversal for every operation which needs to touch all `Pattern` objects - (e.g. deleting a layer everywhere or scaling all patterns). -- Since `Pattern` doesn't know its own name, you can't create a reference by passing in - a `Pattern` object -- you need to know its name. -- You *can* reference a `Pattern` before it is created, so long as you have already decided - on its name. -- Functions like `Pattern.place()` and `Pattern.plug()` need to receive a pattern's name - in order to create a reference, but they also need to access the pattern's ports. - * One way to provide this data is through an `Abstract`, generated via - `Library.abstract()` or through a `Library.abstract_view()`. - * Another way is use `Builder.place()` or `Builder.plug()`, which automatically creates - an `Abstract` from its internally-referenced `Library`. - - -## Glossary -- `Library`: A collection of named cells. OASIS or GDS "library" or file. -- `Tree`: Any `{name: pattern}` mapping which has only one topcell. -- `Pattern`: A collection of geometry, text labels, and reference to other patterns. - OASIS or GDS "Cell", DXF "Block". -- `Ref`: A reference to another pattern. GDS "AREF/SREF", OASIS "Placement". -- `Shape`: Individual geometric entity. OASIS or GDS "Geometry element", DXF "LWPolyline" or "Polyline". -- `repetition`: Repetition operation. OASIS "repetition". - GDS "AREF" is a `Ref` combined with a `Grid` repetition. -- `Label`: Text label. Not rendered into geometry. OASIS, GDS, DXF "Text". -- `annotation`: Additional metadata. OASIS or GDS "property". - - -## Syntax, shorthand, and design patterns -Most syntax and behavior should follow normal python conventions. -There are a few exceptions, either meant to catch common mistakes or to provide a shorthand for common operations: - -### `Library` objects don't allow overwriting already-existing patterns -```python3 -library['mycell'] = pattern0 -library['mycell'] = pattern1 # Error! 'mycell' already exists and can't be overwritten -del library['mycell'] # We can explicitly delete it -library['mycell'] = pattern1 # And now it's ok to assign a new value -library.delete('mycell') # This also deletes all refs pointing to 'mycell' by default -``` - -### Insert a newly-made hierarchical pattern (with children) into a layout -```python3 -# Let's say we have a function which returns a new library containing one topcell (and possibly children) -tree = make_tree(...) - -# To reference this cell in our layout, we have to add all its children to our `library` first: -top_name = tree.top() # get the name of the topcell -name_mapping = library.add(tree) # add all patterns from `tree`, renaming eligible conflicting patterns -new_name = name_mapping.get(top_name, top_name) # get the new name for the cell (in case it was auto-renamed) -my_pattern.ref(new_name, ...) # instantiate the cell - -# This can be accomplished as follows -new_name = library << tree # Add `tree` into `library` and return the top cell's new name -my_pattern.ref(new_name, ...) # instantiate the cell - -# In practice, you may do lots of -my_pattern.ref(lib << make_tree(...), ...) - -# With a `Builder` and `place()`/`plug()` the `lib <<` portion can be implicit: -my_builder = Builder(library=lib, ...) -... -my_builder.place(make_tree(...)) -``` - -We can also use this shorthand to quickly add and reference a single flat (as yet un-named) pattern: -```python3 -anonymous_pattern = Pattern(...) -my_pattern.ref(lib << {'_tentative_name': anonymous_pattern}, ...) -``` - -### Place a hierarchical pattern into a layout, preserving its port info -```python3 -# As above, we have a function that makes a new library containing one topcell (and possibly children) -tree = make_tree(...) - -# We need to go get its port info to `place()` it into our existing layout, -new_name = library << tree # Add the tree to the library and return its name (see `<<` above) -abstract = library.abstract(tree) # An `Abstract` stores a pattern's name and its ports (but no geometry) -my_pattern.place(abstract, ...) - -# With shorthand, -abstract = library <= tree -my_pattern.place(abstract, ...) - -# or -my_pattern.place(library << make_tree(...), ...) -``` - - -### Quickly add geometry, labels, or refs: -Adding elements can be overly verbose: -```python3 -my_pattern.shapes[layer].append(Polygon(vertices, ...)) -my_pattern.labels[layer] += [Label('my text')] -my_pattern.refs[target_name].append(Ref(offset=..., ...)) -``` - -There is shorthand for the most common elements: -```python3 -my_pattern.polygon(layer=layer, vertices=vertices, ...) -my_pattern.rect(layer=layer, xctr=..., xmin=..., ymax=..., ly=...) # rectangle; pick 4 of 6 constraints -my_pattern.rect(layer=layer, ymin=..., ymax=..., xctr=..., lx=...) -my_pattern.path(...) -my_pattern.label(layer, 'my_text') -my_pattern.ref(target_name, offset=..., ...) -``` - -### Accessing ports -```python3 -# Square brackets pull from the underlying `.ports` dict: -assert pattern['input'] is pattern.ports['input'] - -# And you can use them to read multiple ports at once: -assert pattern[('input', 'output')] == { - 'input': pattern.ports['input'], - 'output': pattern.ports['output'], - } - -# But you shouldn't use them for anything except reading -pattern['input'] = Port(...) # Error! -has_input = ('input' in pattern) # Error! -``` - -### Building patterns -```python3 -library = Library(...) -my_pattern_name, my_pattern = library.mkpat(some_name_generator()) -... -def _make_my_subpattern() -> str: - # This function can draw from the outer scope (e.g. `library`) but will not pollute the outer scope - # (e.g. the variable `subpattern` will not be accessible from outside the function; you must load it - # from within `library`). - subpattern_name, subpattern = library.mkpat(...) - subpattern.rect(...) - ... - return subpattern_name -my_pattern.ref(_make_my_subpattern(), offset=..., ...) -``` +## Translation +- `Pattern`: OASIS or GDS "Cell", DXF "Block" +- `SubPattern`: GDS "AREF/SREF", OASIS "Placement" +- `Shape`: OASIS or GDS "Geometry element", DXF "LWPolyline" or "Polyline" +- `repetition`: OASIS "repetition". GDS "AREF" is a `SubPattern` combined with a `Grid` repetition. +- `Label`: OASIS, GDS, DXF "Text". +- `annotation`: OASIS or GDS "property" ## TODO -* Rework naming/args for path-related (Builder, PortPather, path/pathL/pathS/pathU, path_to, mpath) -* PolyCollection & arrow-based read/write -* pather and renderpather examples, including .at() (PortPather) -* Bus-to-bus connections? -* Tests tests tests * Better interface for polygon operations (e.g. with `pyclipper`) - de-embedding - boolean ops -* tuple / string layer auto-translation +* Construct polygons from bitmap using `skimage.find_contours` +* Deal with shape repetitions for dxf, svg diff --git a/examples/ellip_grating.py b/examples/ellip_grating.py index a51a27e..0c34cce 100644 --- a/examples/ellip_grating.py +++ b/examples/ellip_grating.py @@ -2,33 +2,29 @@ import numpy -from masque.file import gdsii -from masque import Arc, Pattern +import masque +import masque.file.klamath +from masque import shapes def main(): - pat = Pattern() - layer = (0, 0) - pat.shapes[layer].extend([ - Arc( + pat = masque.Pattern(name='ellip_grating') + for rmin in numpy.arange(10, 15, 0.5): + pat.shapes.append(shapes.Arc( radii=(rmin, rmin), width=0.1, angles=(-numpy.pi/4, numpy.pi/4), - ) - for rmin in numpy.arange(10, 15, 0.5)] - ) + layer=(0, 0), + )) - pat.label(string='grating centerline', offset=(1, 0), layer=(1, 2)) + pat.labels.append(masque.Label(string='grating centerline', offset=(1, 0), layer=(1, 2))) pat.scale_by(1000) pat.visualize() + pat2 = pat.copy() + pat2.name = 'grating2' - lib = { - 'ellip_grating': pat, - 'grating2': pat.copy(), - } - - gdsii.writefile(lib, 'out.gds.gz', meters_per_unit=1e-9, logical_units_per_unit=1e-3) + masque.file.klamath.writefile((pat, pat2), 'out.gds.gz', 1e-9, 1e-3) if __name__ == '__main__': diff --git a/examples/nested_poly_test.py b/examples/nested_poly_test.py deleted file mode 100644 index de51d6a..0000000 --- a/examples/nested_poly_test.py +++ /dev/null @@ -1,29 +0,0 @@ -import numpy -from pyclipper import ( - Pyclipper, PT_CLIP, PT_SUBJECT, CT_UNION, CT_INTERSECTION, PFT_NONZERO, - scale_to_clipper, scale_from_clipper, - ) -p = Pyclipper() -p.AddPaths([ - [(-10, -10), (-10, 10), (-9, 10), (-9, -10)], - [(-10, 10), (10, 10), (10, 9), (-10, 9)], - [(10, 10), (10, -10), (9, -10), (9, 10)], - [(10, -10), (-10, -10), (-10, -9), (10, -9)], - ], PT_SUBJECT, closed=True) -#p.Execute2? -#p.Execute? -p.Execute(PT_UNION, PT_NONZERO, PT_NONZERO) -p.Execute(CT_UNION, PT_NONZERO, PT_NONZERO) -p.Execute(CT_UNION, PFT_NONZERO, PFT_NONZERO) - -p = Pyclipper() -p.AddPaths([ - [(-10, -10), (-10, 10), (-9, 10), (-9, -10)], - [(-10, 10), (10, 10), (10, 9), (-10, 9)], - [(10, 10), (10, -10), (9, -10), (9, 10)], - [(10, -10), (-10, -10), (-10, -9), (10, -9)], - ], PT_SUBJECT, closed=True) -r = p.Execute2(CT_UNION, PFT_NONZERO, PFT_NONZERO) - -#r.Childs - diff --git a/examples/pic2mask.py b/examples/pic2mask.py deleted file mode 100644 index 0e2516a..0000000 --- a/examples/pic2mask.py +++ /dev/null @@ -1,43 +0,0 @@ -# pip install pillow scikit-image -# or -# sudo apt install python3-pil python3-skimage - -from PIL import Image -from skimage.measure import find_contours -from matplotlib import pyplot -import numpy - -from masque import Pattern, Polygon -from masque.file.gdsii import writefile - -# -# Read the image into a numpy array -# -im = Image.open('./Desktop/Camera/IMG_20220626_091101.jpg') - -aa = numpy.array(im.convert(mode='L').getdata()).reshape(im.height, im.width) - -threshold = (aa.max() - aa.min()) / 2 - -# -# Find edge contours and plot them -# -contours = find_contours(aa, threshold) - -pyplot.imshow(aa) -for contour in contours: - pyplot.plot(contour[:, 1], contour[:, 0], linewidth=2) -pyplot.show(block=False) - -# -# Create the layout from the contours -# -pat = Pattern() -pat.shapes[(0, 0)].extend([ - Polygon(vertices=vv) for vv in contours if len(vv) < 1_000 - ]) - -lib = {} -lib['my_mask_name'] = pat - -writefile(lib, 'test_contours.gds', meters_per_unit=1e-9) diff --git a/examples/test_rep.py b/examples/test_rep.py index f82575d..042e1af 100644 --- a/examples/test_rep.py +++ b/examples/test_rep.py @@ -1,138 +1,103 @@ -from pprint import pprint -from pathlib import Path - import numpy from numpy import pi import masque -from masque import Pattern, Ref, Arc, Library +import masque.file.gdsii +import masque.file.klamath +import masque.file.dxf +import masque.file.oasis +from masque import shapes, Pattern, SubPattern from masque.repetition import Grid -from masque.file import gdsii, dxf, oasis +from pprint import pprint def main(): - lib = Library() - - cell_name = 'ellip_grating' - pat = masque.Pattern() - - layer = (0, 0) + pat = masque.Pattern(name='ellip_grating') for rmin in numpy.arange(10, 15, 0.5): - pat.shapes[layer].append(Arc( + pat.shapes.append(shapes.Arc( radii=(rmin, rmin), width=0.1, - angles=(0 * -pi/4, pi/4), + angles=(0*-numpy.pi/4, numpy.pi/4), annotations={'1': ['blah']}, - )) + )) pat.scale_by(1000) # pat.visualize() - lib[cell_name] = pat - print(f'\nAdded {cell_name}:') + pat2 = pat.copy() + pat2.name = 'grating2' + + pat3 = Pattern('sref_test') + pat3.subpatterns = [ + SubPattern(pat, offset=(1e5, 3e5), annotations={'4': ['Hello I am the base subpattern']}), + SubPattern(pat, offset=(2e5, 3e5), rotation=pi/3), + SubPattern(pat, offset=(3e5, 3e5), rotation=pi/2), + SubPattern(pat, offset=(4e5, 3e5), rotation=pi), + SubPattern(pat, offset=(5e5, 3e5), rotation=3*pi/2), + SubPattern(pat, mirrored=(True, False), offset=(1e5, 4e5)), + SubPattern(pat, mirrored=(True, False), offset=(2e5, 4e5), rotation=pi/3), + SubPattern(pat, mirrored=(True, False), offset=(3e5, 4e5), rotation=pi/2), + SubPattern(pat, mirrored=(True, False), offset=(4e5, 4e5), rotation=pi), + SubPattern(pat, mirrored=(True, False), offset=(5e5, 4e5), rotation=3*pi/2), + SubPattern(pat, mirrored=(False, True), offset=(1e5, 5e5)), + SubPattern(pat, mirrored=(False, True), offset=(2e5, 5e5), rotation=pi/3), + SubPattern(pat, mirrored=(False, True), offset=(3e5, 5e5), rotation=pi/2), + SubPattern(pat, mirrored=(False, True), offset=(4e5, 5e5), rotation=pi), + SubPattern(pat, mirrored=(False, True), offset=(5e5, 5e5), rotation=3*pi/2), + SubPattern(pat, mirrored=(True, True), offset=(1e5, 6e5)), + SubPattern(pat, mirrored=(True, True), offset=(2e5, 6e5), rotation=pi/3), + SubPattern(pat, mirrored=(True, True), offset=(3e5, 6e5), rotation=pi/2), + SubPattern(pat, mirrored=(True, True), offset=(4e5, 6e5), rotation=pi), + SubPattern(pat, mirrored=(True, True), offset=(5e5, 6e5), rotation=3*pi/2), + ] + + pprint(pat3) + pprint(pat3.subpatterns) pprint(pat.shapes) - new_name = lib.get_name(cell_name) - lib[new_name] = pat.copy() - print(f'\nAdded a copy of {cell_name} as {new_name}') - - pat3 = Pattern() - pat3.refs[cell_name] = [ - Ref(offset=(1e5, 3e5), annotations={'4': ['Hello I am the base Ref']}), - Ref(offset=(2e5, 3e5), rotation=pi/3), - Ref(offset=(3e5, 3e5), rotation=pi/2), - Ref(offset=(4e5, 3e5), rotation=pi), - Ref(offset=(5e5, 3e5), rotation=3*pi/2), - Ref(mirrored=True, offset=(1e5, 4e5)), - Ref(mirrored=True, offset=(2e5, 4e5), rotation=pi/3), - Ref(mirrored=True, offset=(3e5, 4e5), rotation=pi/2), - Ref(mirrored=True, offset=(4e5, 4e5), rotation=pi), - Ref(mirrored=True, offset=(5e5, 4e5), rotation=3*pi/2), - Ref(offset=(1e5, 5e5)).mirror_target(1), - Ref(offset=(2e5, 5e5), rotation=pi/3).mirror_target(1), - Ref(offset=(3e5, 5e5), rotation=pi/2).mirror_target(1), - Ref(offset=(4e5, 5e5), rotation=pi).mirror_target(1), - Ref(offset=(5e5, 5e5), rotation=3*pi/2).mirror_target(1), - Ref(offset=(1e5, 6e5)).mirror2d_target(True, True), - Ref(offset=(2e5, 6e5), rotation=pi/3).mirror2d_target(True, True), - Ref(offset=(3e5, 6e5), rotation=pi/2).mirror2d_target(True, True), - Ref(offset=(4e5, 6e5), rotation=pi).mirror2d_target(True, True), - Ref(offset=(5e5, 6e5), rotation=3*pi/2).mirror2d_target(True, True), + rep = Grid(a_vector=[1e4, 0], + b_vector=[0, 1.5e4], + a_count=3, + b_count=2,) + pat4 = Pattern('aref_test') + pat4.subpatterns = [ + SubPattern(pat, repetition=rep, offset=(1e5, 3e5)), + SubPattern(pat, repetition=rep, offset=(2e5, 3e5), rotation=pi/3), + SubPattern(pat, repetition=rep, offset=(3e5, 3e5), rotation=pi/2), + SubPattern(pat, repetition=rep, offset=(4e5, 3e5), rotation=pi), + SubPattern(pat, repetition=rep, offset=(5e5, 3e5), rotation=3*pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(1e5, 4e5)), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(2e5, 4e5), rotation=pi/3), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(3e5, 4e5), rotation=pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(4e5, 4e5), rotation=pi), + SubPattern(pat, repetition=rep, mirrored=(True, False), offset=(5e5, 4e5), rotation=3*pi/2), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(1e5, 5e5)), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(2e5, 5e5), rotation=pi/3), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(3e5, 5e5), rotation=pi/2), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(4e5, 5e5), rotation=pi), + SubPattern(pat, repetition=rep, mirrored=(False, True), offset=(5e5, 5e5), rotation=3*pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(1e5, 6e5)), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(2e5, 6e5), rotation=pi/3), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(3e5, 6e5), rotation=pi/2), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(4e5, 6e5), rotation=pi), + SubPattern(pat, repetition=rep, mirrored=(True, True), offset=(5e5, 6e5), rotation=3*pi/2), ] - lib['sref_test'] = pat3 - print('\nAdded sref_test:') - pprint(pat3) - pprint(pat3.refs) + folder = 'layouts/' + masque.file.klamath.writefile((pat, pat2, pat3, pat4), folder + 'rep.gds.gz', 1e-9, 1e-3) - rep = Grid( - a_vector=[1e4, 0], - b_vector=[0, 1.5e4], - a_count=3, - b_count=2, - ) - pat4 = Pattern() - pat4.refs[cell_name] = [ - Ref(repetition=rep, offset=(1e5, 3e5)), - Ref(repetition=rep, offset=(2e5, 3e5), rotation=pi/3), - Ref(repetition=rep, offset=(3e5, 3e5), rotation=pi/2), - Ref(repetition=rep, offset=(4e5, 3e5), rotation=pi), - Ref(repetition=rep, offset=(5e5, 3e5), rotation=3*pi/2), - Ref(repetition=rep, mirrored=True, offset=(1e5, 4e5)), - Ref(repetition=rep, mirrored=True, offset=(2e5, 4e5), rotation=pi/3), - Ref(repetition=rep, mirrored=True, offset=(3e5, 4e5), rotation=pi/2), - Ref(repetition=rep, mirrored=True, offset=(4e5, 4e5), rotation=pi), - Ref(repetition=rep, mirrored=True, offset=(5e5, 4e5), rotation=3*pi/2), - Ref(repetition=rep, offset=(1e5, 5e5)).mirror_target(1), - Ref(repetition=rep, offset=(2e5, 5e5), rotation=pi/3).mirror_target(1), - Ref(repetition=rep, offset=(3e5, 5e5), rotation=pi/2).mirror_target(1), - Ref(repetition=rep, offset=(4e5, 5e5), rotation=pi).mirror_target(1), - Ref(repetition=rep, offset=(5e5, 5e5), rotation=3*pi/2).mirror_target(1), - Ref(repetition=rep, offset=(1e5, 6e5)).mirror2d_target(True, True), - Ref(repetition=rep, offset=(2e5, 6e5), rotation=pi/3).mirror2d_target(True, True), - Ref(repetition=rep, offset=(3e5, 6e5), rotation=pi/2).mirror2d_target(True, True), - Ref(repetition=rep, offset=(4e5, 6e5), rotation=pi).mirror2d_target(True, True), - Ref(repetition=rep, offset=(5e5, 6e5), rotation=3*pi/2).mirror2d_target(True, True), - ] + cells = list(masque.file.klamath.readfile(folder + 'rep.gds.gz')[0].values()) + masque.file.klamath.writefile(cells, folder + 'rerep.gds.gz', 1e-9, 1e-3) - lib['aref_test'] = pat4 - print('\nAdded aref_test') - - folder = Path('./layouts/') - folder.mkdir(exist_ok=True) - print(f'...writing files to {folder}...') - - gds1 = folder / 'rep.gds.gz' - gds2 = folder / 'rerep.gds.gz' - print(f'Initial write to {gds1}') - gdsii.writefile(lib, gds1, 1e-9, 1e-3) - - print(f'Read back and rewrite to {gds2}') - readback_lib, _info = gdsii.readfile(gds1) - gdsii.writefile(readback_lib, gds2, 1e-9, 1e-3) - - dxf1 = folder / 'rep.dxf.gz' - dxf2 = folder / 'rerep.dxf.gz' - print(f'Write aref_test to {dxf1}') - dxf.writefile(lib, 'aref_test', dxf1) - - print(f'Read back and rewrite to {dxf2}') - dxf_lib, _info = dxf.readfile(dxf1) - print(Library(dxf_lib)) - dxf.writefile(dxf_lib, 'Model', dxf2) + masque.file.dxf.writefile(pat4, folder + 'rep.dxf.gz') + dxf, info = masque.file.dxf.readfile(folder + 'rep.dxf.gz') + masque.file.dxf.writefile(dxf, folder + 'rerep.dxf.gz') layer_map = {'base': (0,0), 'mylabel': (1,2)} - oas1 = folder / 'rep.oas' - oas2 = folder / 'rerep.oas' - print(f'Write lib to {oas1}') - oasis.writefile(lib, oas1, 1000, layer_map=layer_map) - - print(f'Read back and rewrite to {oas2}') - oas_lib, oas_info = oasis.readfile(oas1) - oasis.writefile(oas_lib, oas2, 1000, layer_map=layer_map) - - print('OASIS info:') - pprint(oas_info) + masque.file.oasis.writefile((pat, pat2, pat3, pat4), folder + 'rep.oas.gz', 1000, layer_map=layer_map) + oas, info = masque.file.oasis.readfile(folder + 'rep.oas.gz') + masque.file.oasis.writefile(list(oas.values()), folder + 'rerep.oas.gz', 1000, layer_map=layer_map) + print(info) if __name__ == '__main__': diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md index 7210a93..e69de29 100644 --- a/examples/tutorial/README.md +++ b/examples/tutorial/README.md @@ -1,39 +0,0 @@ -masque Tutorial -=============== - -Contents --------- - -- [basic_shapes](basic_shapes.py): - * Draw basic geometry - * Export to GDS -- [devices](devices.py) - * Reference other patterns - * Add ports to a pattern - * Snap ports together to build a circuit - * Check for dangling references -- [library](library.py) - * Create a `LazyLibrary`, which loads / generates patterns only when they are first used - * Explore alternate ways of specifying a pattern for `.plug()` and `.place()` - * Design a pattern which is meant to plug into an existing pattern (via `.interface()`) -- [pather](pather.py) - * Use `Pather` to route individual wires and wire bundles - * Use `BasicTool` to generate paths - * Use `BasicTool` to automatically transition between path types -- [renderpather](rendpather.py) - * Use `RenderPather` and `PathTool` to build a layout similar to the one in [pather](pather.py), - but using `Path` shapes instead of `Polygon`s. - - -Additionaly, [pcgen](pcgen.py) is a utility module for generating photonic crystal lattices. - - -Running -------- - -Run from inside the examples directory: -```bash -cd examples/tutorial -python3 basic_shapes.py -klayout -e basic_shapes.gds -``` diff --git a/examples/tutorial/basic_shapes.py b/examples/tutorial/basic_shapes.py index 87baaf0..ccd89ff 100644 --- a/examples/tutorial/basic_shapes.py +++ b/examples/tutorial/basic_shapes.py @@ -1,21 +1,21 @@ -from collections.abc import Sequence +from typing import Tuple, Sequence import numpy from numpy import pi -from masque import ( - layer_t, Pattern, Label, Port, - Circle, Arc, Polygon, - ) +from masque import layer_t, Pattern, SubPattern, Label +from masque.shapes import Circle, Arc, Polygon +from masque.builder import Device, Port +from masque.library import Library, DeviceLibrary import masque.file.gdsii # Note that masque units are arbitrary, and are only given # physical significance when writing to a file. -GDS_OPTS = dict( - meters_per_unit = 1e-9, # GDS database unit, 1 nanometer - logical_units_per_unit = 1e-3, # GDS display unit, 1 micron - ) +GDS_OPTS = { + 'meters_per_unit': 1e-9, # GDS database unit, 1 nanometer + 'logical_units_per_unit': 1e-3, # GDS display unit, 1 micron +} def hole( @@ -30,12 +30,11 @@ def hole( layer: Layer to draw the circle on. Returns: - Pattern containing a circle. + Pattern, named `'hole'` """ - pat = Pattern() - pat.shapes[layer].append( - Circle(radius=radius, offset=(0, 0)) - ) + pat = Pattern('hole', shapes=[ + Circle(radius=radius, offset=(0, 0), layer=layer) + ]) return pat @@ -51,17 +50,16 @@ def triangle( layer: Layer to draw the circle on. Returns: - Pattern containing a triangle + Pattern, named `'triangle'` """ vertices = numpy.array([ (numpy.cos( pi / 2), numpy.sin( pi / 2)), (numpy.cos(pi + pi / 6), numpy.sin(pi + pi / 6)), (numpy.cos( - pi / 6), numpy.sin( - pi / 6)), - ]) * radius + ]) * radius - pat = Pattern() - pat.shapes[layer].extend([ - Polygon(offset=(0, 0), vertices=vertices), + pat = Pattern('triangle', shapes=[ + Polygon(offset=(0, 0), layer=layer, vertices=vertices), ]) return pat @@ -80,40 +78,37 @@ def smile( secondary_layer: Layer to draw eyes and smile on. Returns: - Pattern containing a smiley face + Pattern, named `'smile'` """ # Make an empty pattern - pat = Pattern() + pat = Pattern('smile') # Add all the shapes we want - pat.shapes[layer] += [ - Circle(radius=radius, offset=(0, 0)), # Outer circle - ] - - pat.shapes[secondary_layer] += [ - Circle(radius=radius / 10, offset=(radius / 3, radius / 3)), - Circle(radius=radius / 10, offset=(-radius / 3, radius / 3)), - Arc( - radii=(radius * 2 / 3, radius * 2 / 3), # Underlying ellipse radii + pat.shapes += [ + Circle(radius=radius, offset=(0, 0), layer=layer), # Outer circle + Circle(radius=radius / 10, offset=(radius / 3, radius / 3), layer=secondary_layer), + Circle(radius=radius / 10, offset=(-radius / 3, radius / 3), layer=secondary_layer), + Arc(radii=(radius * 2 / 3, radius * 2 / 3), # Underlying ellipse radii angles=(7 / 6 * pi, 11 / 6 * pi), # Angles limiting the arc width=radius / 10, offset=(0, 0), - ), + layer=secondary_layer), ] return pat def main() -> None: - lib = {} + hole_pat = hole(1000) + smile_pat = smile(1000) + tri_pat = triangle(1000) - lib['hole'] = hole(1000) - lib['smile'] = smile(1000) - lib['triangle'] = triangle(1000) + units_per_meter = 1e-9 + units_per_display_unit = 1e-3 - masque.file.gdsii.writefile(lib, 'basic_shapes.gds', **GDS_OPTS) + masque.file.gdsii.writefile([hole_pat, tri_pat, smile_pat], 'basic_shapes.gds', **GDS_OPTS) - lib['triangle'].visualize() + smile_pat.visualize() if __name__ == '__main__': diff --git a/examples/tutorial/devices.py b/examples/tutorial/devices.py index 6b9cfa2..5cae36e 100644 --- a/examples/tutorial/devices.py +++ b/examples/tutorial/devices.py @@ -1,14 +1,12 @@ -from collections.abc import Sequence, Mapping +from typing import Tuple, Sequence, Dict import numpy from numpy import pi -from masque import ( - layer_t, Pattern, Ref, Label, Builder, Port, Polygon, - Library, ILibraryView, - ) -from masque.utils import ports2data -from masque.file.gdsii import writefile, check_valid_names +from masque import layer_t, Pattern, SubPattern, Label +from masque.shapes import Polygon +from masque.builder import Device, Port, port_utils +from masque.file.gdsii import writefile import pcgen import basic_shapes @@ -19,41 +17,40 @@ LATTICE_CONSTANT = 512 RADIUS = LATTICE_CONSTANT / 2 * 0.75 -def ports_to_data(pat: Pattern) -> Pattern: +def dev2pat(dev: Device) -> Pattern: """ - Bake port information into the pattern. + Bake port information into the device. This places a label at each port location on layer (3, 0) with text content 'name:ptype angle_deg' """ - return ports2data.ports_to_data(pat, layer=(3, 0)) + return port_utils.dev2pat(dev, layer=(3, 0)) -def data_to_ports(lib: Mapping[str, Pattern], name: str, pat: Pattern) -> Pattern: +def pat2dev(pat: Pattern) -> Device: """ - Scan the Pattern to determine port locations. Same port format as `ports_to_data` + Scans the Pattern to determine port locations. Same format as `dev2pat` """ - return ports2data.data_to_ports(layers=[(3, 0)], library=lib, pattern=pat, name=name) + return port_utils.pat2dev(pat, layers=[(3, 0)]) def perturbed_l3( lattice_constant: float, - hole: str, - hole_lib: Mapping[str, Pattern], + hole: Pattern, + trench_dose: float = 1.0, trench_layer: layer_t = (1, 0), shifts_a: Sequence[float] = (0.15, 0, 0.075), shifts_r: Sequence[float] = (1.0, 1.0, 1.0), - xy_size: tuple[int, int] = (10, 10), + xy_size: Tuple[int, int] = (10, 10), perturbed_radius: float = 1.1, trench_width: float = 1200, - ) -> Pattern: + ) -> Device: """ - Generate a `Pattern` representing a perturbed L3 cavity. + Generate a `Device` representing a perturbed L3 cavity. Args: lattice_constant: Distance between nearest neighbor holes - hole: name of a `Pattern` containing a single hole - hole_lib: Library which contains the `Pattern` object for hole. - Necessary because we need to know how big it is... + hole: `Pattern` object containing a single hole + trench_dose: Dose for the trenches. Default 1.0. (Hole dose is 1.0.) trench_layer: Layer for the trenches, default `(1, 0)`. shifts_a: passed to `pcgen.l3_shift`; specifies lattice constant (1 - multiplicative factor) for shifting holes adjacent to @@ -69,10 +66,8 @@ def perturbed_l3( trench width: Width of the undercut trenches. Default 1200. Returns: - `Pattern` object representing the L3 design. + `Device` object representing the L3 design. """ - print('Generating perturbed L3...') - # Get hole positions and radii xyr = pcgen.l3_shift_perturbed_defect(mirror_dims=xy_size, perturbed_radius=perturbed_radius, @@ -80,206 +75,188 @@ def perturbed_l3( shifts_r=shifts_r) # Build L3 cavity, using references to the provided hole pattern - pat = Pattern() - pat.refs[hole] += [ - Ref(scale=r, offset=(lattice_constant * x, - lattice_constant * y)) + pat = Pattern(f'L3p-a{lattice_constant:g}rp{perturbed_radius:g}') + pat.subpatterns += [ + SubPattern(hole, scale=r, + offset=(lattice_constant * x, + lattice_constant * y)) for x, y, r in xyr] # Add rectangular undercut aids - min_xy, max_xy = pat.get_bounds_nonempty(hole_lib) + min_xy, max_xy = pat.get_bounds_nonempty() trench_dx = max_xy[0] - min_xy[0] - pat.shapes[trench_layer] += [ - Polygon.rect(ymin=max_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width), - Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width), + pat.shapes += [ + Polygon.rect(ymin=max_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width, + layer=trench_layer, dose=trench_dose), + Polygon.rect(ymax=min_xy[1], xmin=min_xy[0], lx=trench_dx, ly=trench_width, + layer=trench_layer, dose=trench_dose), ] # Ports are at outer extents of the device (with y=0) extent = lattice_constant * xy_size[0] - pat.ports = dict( - input=Port((-extent, 0), rotation=0, ptype='pcwg'), - output=Port((extent, 0), rotation=pi, ptype='pcwg'), - ) + ports = { + 'input': Port((-extent, 0), rotation=0, ptype='pcwg'), + 'output': Port((extent, 0), rotation=pi, ptype='pcwg'), + } - ports_to_data(pat) - return pat + return Device(pat, ports) def waveguide( lattice_constant: float, - hole: str, + hole: Pattern, length: int, mirror_periods: int, - ) -> Pattern: + ) -> Device: """ - Generate a `Pattern` representing a photonic crystal line-defect waveguide. + Generate a `Device` representing a photonic crystal line-defect waveguide. Args: lattice_constant: Distance between nearest neighbor holes - hole: name of a `Pattern` containing a single hole + hole: `Pattern` object containing a single hole length: Distance (number of mirror periods) between the input and output ports. Ports are placed at lattice sites. mirror_periods: Number of hole rows on each side of the line defect Returns: - `Pattern` object representing the waveguide. + `Device` object representing the waveguide. """ # Generate hole locations xy = pcgen.waveguide(length=length, num_mirror=mirror_periods) # Build the pattern - pat = Pattern() - pat.refs[hole] += [ - Ref(offset=(lattice_constant * x, - lattice_constant * y)) - for x, y in xy] + pat = Pattern(f'_wg-a{lattice_constant:g}l{length}') + pat.subpatterns += [SubPattern(hole, offset=(lattice_constant * x, + lattice_constant * y)) + for x, y in xy] # Ports are at outer edges, with y=0 extent = lattice_constant * length / 2 - pat.ports = dict( - left=Port((-extent, 0), rotation=0, ptype='pcwg'), - right=Port((extent, 0), rotation=pi, ptype='pcwg'), - ) - - ports_to_data(pat) - return pat + ports = { + 'left': Port((-extent, 0), rotation=0, ptype='pcwg'), + 'right': Port((extent, 0), rotation=pi, ptype='pcwg'), + } + return Device(pat, ports) def bend( lattice_constant: float, - hole: str, + hole: Pattern, mirror_periods: int, - ) -> Pattern: + ) -> Device: """ - Generate a `Pattern` representing a 60-degree counterclockwise bend in a photonic crystal + Generate a `Device` representing a 60-degree counterclockwise bend in a photonic crystal line-defect waveguide. Args: lattice_constant: Distance between nearest neighbor holes - hole: name of a `Pattern` containing a single hole + hole: `Pattern` object containing a single hole mirror_periods: Minimum number of mirror periods on each side of the line defect. Returns: - `Pattern` object representing the waveguide bend. + `Device` object representing the waveguide bend. Ports are named 'left' (input) and 'right' (output). """ # Generate hole locations xy = pcgen.wgbend(num_mirror=mirror_periods) # Build the pattern - pat= Pattern() - pat.refs[hole] += [ - Ref(offset=(lattice_constant * x, - lattice_constant * y)) + pat= Pattern(f'_wgbend-a{lattice_constant:g}l{mirror_periods}') + pat.subpatterns += [ + SubPattern(hole, offset=(lattice_constant * x, + lattice_constant * y)) for x, y in xy] # Figure out port locations. extent = lattice_constant * mirror_periods - pat.ports = dict( - left=Port((-extent, 0), rotation=0, ptype='pcwg'), - right=Port((extent / 2, - extent * numpy.sqrt(3) / 2), - rotation=pi * 4 / 3, ptype='pcwg'), - ) - ports_to_data(pat) - return pat + ports = { + 'left': Port((-extent, 0), rotation=0, ptype='pcwg'), + 'right': Port((extent / 2, + extent * numpy.sqrt(3) / 2), + rotation=pi * 4 / 3, ptype='pcwg'), + } + return Device(pat, ports) def y_splitter( lattice_constant: float, - hole: str, + hole: Pattern, mirror_periods: int, - ) -> Pattern: + ) -> Device: """ - Generate a `Pattern` representing a photonic crystal line-defect waveguide y-splitter. + Generate a `Device` representing a photonic crystal line-defect waveguide y-splitter. Args: lattice_constant: Distance between nearest neighbor holes - hole: name of a `Pattern` containing a single hole + hole: `Pattern` object containing a single hole mirror_periods: Minimum number of mirror periods on each side of the line defect. Returns: - `Pattern` object representing the y-splitter. + `Device` object representing the y-splitter. Ports are named 'in', 'top', and 'bottom'. """ # Generate hole locations xy = pcgen.y_splitter(num_mirror=mirror_periods) # Build pattern - pat = Pattern() - pat.refs[hole] += [ - Ref(offset=(lattice_constant * x, - lattice_constant * y)) + pat = Pattern(f'_wgsplit_half-a{lattice_constant:g}l{mirror_periods}') + pat.subpatterns += [ + SubPattern(hole, offset=(lattice_constant * x, + lattice_constant * y)) for x, y in xy] # Determine port locations extent = lattice_constant * mirror_periods - pat.ports = { + ports = { 'in': Port((-extent, 0), rotation=0, ptype='pcwg'), 'top': Port((extent / 2, extent * numpy.sqrt(3) / 2), rotation=pi * 4 / 3, ptype='pcwg'), 'bot': Port((extent / 2, -extent * numpy.sqrt(3) / 2), rotation=pi * 2 / 3, ptype='pcwg'), } - - ports_to_data(pat) - return pat + return Device(pat, ports) -def main(interactive: bool = True) -> None: +def main(interactive: bool = True): # Generate some basic hole patterns - shape_lib = { - 'smile': basic_shapes.smile(RADIUS), - 'hole': basic_shapes.hole(RADIUS), - } + smile = basic_shapes.smile(RADIUS) + hole = basic_shapes.hole(RADIUS) # Build some devices a = LATTICE_CONSTANT + wg10 = waveguide(lattice_constant=a, hole=hole, length=10, mirror_periods=5).rename('wg10') + wg05 = waveguide(lattice_constant=a, hole=hole, length=5, mirror_periods=5).rename('wg05') + wg28 = waveguide(lattice_constant=a, hole=hole, length=28, mirror_periods=5).rename('wg28') + bend0 = bend(lattice_constant=a, hole=hole, mirror_periods=5).rename('bend0') + ysplit = y_splitter(lattice_constant=a, hole=hole, mirror_periods=5).rename('ysplit') + l3cav = perturbed_l3(lattice_constant=a, hole=smile, xy_size=(4, 10)).rename('l3cav') # uses smile :) - devices = {} - devices['wg05'] = waveguide(lattice_constant=a, hole='hole', length=5, mirror_periods=5) - devices['wg10'] = waveguide(lattice_constant=a, hole='hole', length=10, mirror_periods=5) - devices['wg28'] = waveguide(lattice_constant=a, hole='hole', length=28, mirror_periods=5) - devices['wg90'] = waveguide(lattice_constant=a, hole='hole', length=90, mirror_periods=5) - devices['bend0'] = bend(lattice_constant=a, hole='hole', mirror_periods=5) - devices['ysplit'] = y_splitter(lattice_constant=a, hole='hole', mirror_periods=5) - devices['l3cav'] = perturbed_l3(lattice_constant=a, hole='smile', hole_lib=shape_lib, xy_size=(4, 10)) # uses smile :) - - # Turn our dict of devices into a Library. - # This provides some convenience functions in the future! - lib = Library(devices) + # Autogenerate port labels so that GDS will also contain port data + for device in [wg10, wg05, wg28, l3cav, ysplit, bend0]: + dev2pat(device) # # Build a circuit # - # Create a `Builder`, and add the circuit to our library as "my_circuit". - circ = Builder(library=lib, name='my_circuit') + circ = Device(name='my_circuit', ports={}) # Start by placing a waveguide. Call its ports "in" and "signal". - circ.place('wg10', offset=(0, 0), port_map={'left': 'in', 'right': 'signal'}) + circ.place(wg10, offset=(0, 0), port_map={'left': 'in', 'right': 'signal'}) # Extend the signal path by attaching the "left" port of a waveguide. # Since there is only one other port ("right") on the waveguide we # are attaching (wg10), it automatically inherits the name "signal". - circ.plug('wg10', {'signal': 'left'}) - - # We could have done the following instead: - # circ_pat = Pattern() - # lib['my_circuit'] = circ_pat - # circ_pat.place(lib.abstract('wg10'), ...) - # circ_pat.plug(lib.abstract('wg10'), ...) - # but `Builder` lets us omit some of the repetition of `lib.abstract(...)`, and uses similar - # syntax to `Pather` and `RenderPather`, which add wire/waveguide routing functionality. + circ.plug(wg10, {'signal': 'left'}) # Attach a y-splitter to the signal path. # Since the y-splitter has 3 ports total, we can't auto-inherit the # port name, so we have to specify what we want to name the unattached # ports. We can call them "signal1" and "signal2". - circ.plug('ysplit', {'signal': 'in'}, {'top': 'signal1', 'bot': 'signal2'}) + circ.plug(ysplit, {'signal': 'in'}, {'top': 'signal1', 'bot': 'signal2'}) # Add a waveguide to both signal ports, inheriting their names. - circ.plug('wg05', {'signal1': 'left'}) - circ.plug('wg05', {'signal2': 'left'}) + circ.plug(wg05, {'signal1': 'left'}) + circ.plug(wg05, {'signal2': 'left'}) # Add a bend to both ports. # Our bend's ports "left" and "right" refer to the original counterclockwise @@ -288,22 +265,22 @@ def main(interactive: bool = True) -> None: # to "signal2" to bend counterclockwise. # We could also use `mirrored=(True, False)` to mirror one of the devices # and then use same device port on both paths. - circ.plug('bend0', {'signal1': 'right'}) - circ.plug('bend0', {'signal2': 'left'}) + circ.plug(bend0, {'signal1': 'right'}) + circ.plug(bend0, {'signal2': 'left'}) # We add some waveguides and a cavity to "signal1". - circ.plug('wg10', {'signal1': 'left'}) - circ.plug('l3cav', {'signal1': 'input'}) - circ.plug('wg10', {'signal1': 'left'}) + circ.plug(wg10, {'signal1': 'left'}) + circ.plug(l3cav, {'signal1': 'input'}) + circ.plug(wg10, {'signal1': 'left'}) # "signal2" just gets a single of equivalent length - circ.plug('wg28', {'signal2': 'left'}) + circ.plug(wg28, {'signal2': 'left'}) # Now we bend both waveguides back towards each other - circ.plug('bend0', {'signal1': 'right'}) - circ.plug('bend0', {'signal2': 'left'}) - circ.plug('wg05', {'signal1': 'left'}) - circ.plug('wg05', {'signal2': 'left'}) + circ.plug(bend0, {'signal1': 'right'}) + circ.plug(bend0, {'signal2': 'left'}) + circ.plug(wg05, {'signal1': 'left'}) + circ.plug(wg05, {'signal2': 'left'}) # To join the waveguides, we attach a second y-junction. # We plug "signal1" into the "bot" port, and "signal2" into the "top" port. @@ -311,34 +288,23 @@ def main(interactive: bool = True) -> None: # This operation would raise an exception if the ports did not line up # correctly (i.e. they required different rotations or translations of the # y-junction device). - circ.plug('ysplit', {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'}) + circ.plug(ysplit, {'signal1': 'bot', 'signal2': 'top'}, {'in': 'signal_out'}) # Finally, add some more waveguide to "signal_out". - circ.plug('wg10', {'signal_out': 'left'}) - - # We can also add text labels for our circuit's ports. - # They will appear at the uppermost hierarchy level, while the individual - # device ports will appear further down, in their respective cells. - ports_to_data(circ.pattern) - - # Check if we forgot to include any patterns... ooops! - if dangling := lib.dangling_refs(): - print('Warning: The following patterns are referenced, but not present in the' - f' library! {dangling}') - print('We\'ll solve this by merging in shape_lib, which contains those shapes...') - - lib.add(shape_lib) - assert not lib.dangling_refs() + circ.plug(wg10, {'signal_out': 'left'}) # We can visualize the design. Usually it's easier to just view the GDS. if interactive: print('Visualizing... this step may be slow') - circ.pattern.visualize(lib) + circ.pattern.visualize() - #Write out to GDS, only keeping patterns referenced by our circuit (including itself) - subtree = lib.subtree('my_circuit') # don't include wg90, which we don't use - check_valid_names(subtree.keys()) - writefile(subtree, 'circuit.gds', **GDS_OPTS) + # We can also add text labels for our circuit's ports. + # They will appear at the uppermost hierarchy level, while the individual + # device ports will appear further down, in their respective cells. + dev2pat(circ) + + # Write out to GDS + writefile(circ.pattern, 'circuit.gds', **GDS_OPTS) if __name__ == '__main__': diff --git a/examples/tutorial/library.py b/examples/tutorial/library.py index eab8a12..2dc2af6 100644 --- a/examples/tutorial/library.py +++ b/examples/tutorial/library.py @@ -1,83 +1,81 @@ -from typing import Any -from collections.abc import Sequence, Callable +from typing import Tuple, Sequence, Callable from pprint import pformat import numpy from numpy import pi -from masque import Pattern, Builder, LazyLibrary +from masque.builder import Device +from masque.library import Library, LibDeviceLibrary from masque.file.gdsii import writefile, load_libraryfile import pcgen import basic_shapes import devices -from devices import ports_to_data, data_to_ports +from devices import pat2dev, dev2pat from basic_shapes import GDS_OPTS def main() -> None: - # Define a `LazyLibrary`, which provides lazy evaluation for generating - # patterns and lazy-loading of GDS contents. - lib = LazyLibrary() + # Define a `Library`-backed `DeviceLibrary`, which provides lazy evaluation + # for device generation code and lazy-loading of GDS contents. + device_lib = LibDeviceLibrary() # # Load some devices from a GDS file # # Scan circuit.gds and prepare to lazy-load its contents - gds_lib, _properties = load_libraryfile('circuit.gds', postprocess=data_to_ports) + pattern_lib, _properties = load_libraryfile('circuit.gds', tag='mycirc01') # Add it into the device library by providing a way to read port info # This maintains the lazy evaluation from above, so no patterns # are actually read yet. - lib.add(gds_lib) + device_lib.add_library(pattern_lib, pat2dev=pat2dev) + + print('Devices loaded from GDS into library:\n' + pformat(list(device_lib.keys()))) - print('Patterns loaded from GDS into library:\n' + pformat(list(lib.keys()))) # # Add some new devices to the library, this time from python code rather than GDS # - lib['triangle'] = lambda: basic_shapes.triangle(devices.RADIUS) - opts: dict[str, Any] = dict( - lattice_constant = devices.LATTICE_CONSTANT, - hole = 'triangle', - ) + a = devices.LATTICE_CONSTANT + tri = basic_shapes.triangle(devices.RADIUS) + + # Convenience function for adding devices + # This is roughly equivalent to + # `device_lib[name] = lambda: dev2pat(fn())` + # but it also guarantees that the resulting pattern is named `name`. + def add(name: str, fn: Callable[[], Device]) -> None: + device_lib.add_device(name=name, fn=fn, dev2pat=dev2pat) # Triangle-based variants. These are defined here, but they won't run until they're # retrieved from the library. - lib['tri_wg10'] = lambda: devices.waveguide(length=10, mirror_periods=5, **opts) - lib['tri_wg05'] = lambda: devices.waveguide(length=5, mirror_periods=5, **opts) - lib['tri_wg28'] = lambda: devices.waveguide(length=28, mirror_periods=5, **opts) - lib['tri_bend0'] = lambda: devices.bend(mirror_periods=5, **opts) - lib['tri_ysplit'] = lambda: devices.y_splitter(mirror_periods=5, **opts) - lib['tri_l3cav'] = lambda: devices.perturbed_l3(xy_size=(4, 10), **opts, hole_lib=lib) + add('tri_wg10', lambda: devices.waveguide(lattice_constant=a, hole=tri, length=10, mirror_periods=5)) + add('tri_wg05', lambda: devices.waveguide(lattice_constant=a, hole=tri, length=5, mirror_periods=5)) + add('tri_wg28', lambda: devices.waveguide(lattice_constant=a, hole=tri, length=28, mirror_periods=5)) + add('tri_bend0', lambda: devices.bend(lattice_constant=a, hole=tri, mirror_periods=5)) + add('tri_ysplit', lambda: devices.y_splitter(lattice_constant=a, hole=tri, mirror_periods=5)) + add('tri_l3cav', lambda: devices.perturbed_l3(lattice_constant=a, hole=tri, xy_size=(4, 10))) + # # Build a mixed waveguide with an L3 cavity in the middle # # Immediately start building from an instance of the L3 cavity - circ2 = Builder(library=lib, ports='tri_l3cav') + circ2 = device_lib['tri_l3cav'].build('mixed_wg_cav') - # First way to get abstracts is `lib.abstract(name)` - # We can use this syntax directly with `Pattern.plug()` and `Pattern.place()` as well as through `Builder`. - circ2.plug(lib.abstract('wg10'), {'input': 'right'}) - - # Second way to get abstracts is to use an AbstractView - # This also works directly with `Pattern.plug()` / `Pattern.place()`. - abstracts = lib.abstract_view() - circ2.plug(abstracts['wg10'], {'output': 'left'}) - - # Third way to specify an abstract works by automatically getting - # it from the library already within the Builder object. - # This wouldn't work if we only had a `Pattern` (not a `Builder`). - # Just pass the pattern name! - circ2.plug('tri_wg10', {'input': 'right'}) - circ2.plug('tri_wg10', {'output': 'left'}) + print(device_lib['wg10'].ports) + circ2.plug(device_lib['wg10'], {'input': 'right'}) + circ2.plug(device_lib['wg10'], {'output': 'left'}) + circ2.plug(device_lib['tri_wg10'], {'input': 'right'}) + circ2.plug(device_lib['tri_wg10'], {'output': 'left'}) # Add the circuit to the device library. - lib['mixed_wg_cav'] = circ2.pattern + # It has already been generated, so we can use `set_const` as a shorthand for + # `device_lib['mixed_wg_cav'] = lambda: circ2` + device_lib.set_const(circ2) # @@ -85,26 +83,29 @@ def main() -> None: # # We'll be designing against an existing device's interface... - circ3 = Builder.interface(source=circ2) - + circ3 = circ2.as_interface('loop_segment') # ... that lets us continue from where we left off. - circ3.plug('tri_bend0', {'input': 'right'}) - circ3.plug('tri_bend0', {'input': 'left'}, mirrored=True) # mirror since no tri y-symmetry - circ3.plug('tri_bend0', {'input': 'right'}) - circ3.plug('bend0', {'output': 'left'}) - circ3.plug('bend0', {'output': 'left'}) - circ3.plug('bend0', {'output': 'left'}) - circ3.plug('tri_wg10', {'input': 'right'}) - circ3.plug('tri_wg28', {'input': 'right'}) - circ3.plug('tri_wg10', {'input': 'right', 'output': 'left'}) + circ3.plug(device_lib['tri_bend0'], {'input': 'right'}) + circ3.plug(device_lib['tri_bend0'], {'input': 'left'}, mirrored=(True, False)) # mirror since no tri y-symmetry + circ3.plug(device_lib['tri_bend0'], {'input': 'right'}) + circ3.plug(device_lib['bend0'], {'output': 'left'}) + circ3.plug(device_lib['bend0'], {'output': 'left'}) + circ3.plug(device_lib['bend0'], {'output': 'left'}) + circ3.plug(device_lib['tri_wg10'], {'input': 'right'}) + circ3.plug(device_lib['tri_wg28'], {'input': 'right'}) + circ3.plug(device_lib['tri_wg10'], {'input': 'right', 'output': 'left'}) - lib['loop_segment'] = circ3.pattern + device_lib.set_const(circ3) # # Write all devices into a GDS file # - print('Writing library to file...') - writefile(lib, 'library.gds', **GDS_OPTS) + + # This line could be slow, since it generates or loads many of the devices + # since they were not all accessed above. + all_device_pats = [dev.pattern for dev in device_lib.values()] + + writefile(all_device_pats, 'library.gds', **GDS_OPTS) if __name__ == '__main__': @@ -115,21 +116,22 @@ if __name__ == '__main__': #class prout: # def place( # self, -# other: Pattern, +# other: Device, # label_layer: layer_t = 'WATLAYER', # *, -# port_map: Dict[str, str | None] | None = None, +# port_map: Optional[Dict[str, Optional[str]]] = None, # **kwargs, # ) -> 'prout': # -# Pattern.place(self, other, port_map=port_map, **kwargs) -# name: str | None +# Device.place(self, other, port_map=port_map, **kwargs) +# name: Optional[str] # for name in other.ports: # if port_map: # assert(name is not None) # name = port_map.get(name, name) # if name is None: # continue -# self.pattern.label(string=name, offset=self.ports[name].offset, layer=label_layer) +# self.pattern.labels += [ +# Label(string=name, offset=self.ports[name].offset, layer=layer)] # return self # diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py deleted file mode 100644 index 101fbb5..0000000 --- a/examples/tutorial/pather.py +++ /dev/null @@ -1,283 +0,0 @@ -""" -Manual wire routing tutorial: Pather and BasicTool -""" -from collections.abc import Callable -from numpy import pi -from masque import Pather, RenderPather, Library, Pattern, Port, layer_t, map_layers -from masque.builder.tools import BasicTool, PathTool -from masque.file.gdsii import writefile - -from basic_shapes import GDS_OPTS - -# -# Define some basic wire widths, in nanometers -# M2 is the top metal; M1 is below it and connected with vias on V1 -# -M1_WIDTH = 1000 -V1_WIDTH = 500 -M2_WIDTH = 4000 - -# -# First, we can define some functions for generating our wire geometry -# - -def make_pad() -> Pattern: - """ - Create a pattern with a single rectangle of M2, with a single port on the bottom - - Every pad will be an instance of the same pattern, so we will only call this function once. - """ - pat = Pattern() - pat.rect(layer='M2', xctr=0, yctr=0, lx=3 * M2_WIDTH, ly=4 * M2_WIDTH) - pat.ports['wire_port'] = Port((0, -2 * M2_WIDTH), rotation=pi / 2, ptype='m2wire') - return pat - - -def make_via( - layer_top: layer_t, - layer_via: layer_t, - layer_bot: layer_t, - width_top: float, - width_via: float, - width_bot: float, - ptype_top: str, - ptype_bot: str, - ) -> Pattern: - """ - Generate three concentric squares, on the provided layers - (`layer_top`, `layer_via`, `layer_bot`) and with the provided widths - (`width_top`, `width_via`, `width_bot`). - - Two ports are added, with the provided ptypes (`ptype_top`, `ptype_bot`). - They are placed at the left edge of the top layer and right edge of the - bottom layer, respectively. - - We only have one via type, so we will only call this function once. - """ - pat = Pattern() - pat.rect(layer=layer_via, xctr=0, yctr=0, lx=width_via, ly=width_via) - pat.rect(layer=layer_bot, xctr=0, yctr=0, lx=width_bot, ly=width_bot) - pat.rect(layer=layer_top, xctr=0, yctr=0, lx=width_top, ly=width_top) - pat.ports = { - 'top': Port(offset=(-width_top / 2, 0), rotation=0, ptype=ptype_top), - 'bottom': Port(offset=(width_bot / 2, 0), rotation=pi, ptype=ptype_bot), - } - return pat - - -def make_bend(layer: layer_t, width: float, ptype: str) -> Pattern: - """ - Generate a triangular wire, with ports at the left (input) and bottom (output) edges. - This is effectively a clockwise wire bend. - - Every bend will be the same, so we only need to call this twice (once each for M1 and M2). - We could call it additional times for different wire widths or bend types (e.g. squares). - """ - pat = Pattern() - pat.polygon(layer=layer, vertices=[(0, -width / 2), (0, width / 2), (width, -width / 2)]) - pat.ports = { - 'input': Port(offset=(0, 0), rotation=0, ptype=ptype), - 'output': Port(offset=(width / 2, -width / 2), rotation=pi / 2, ptype=ptype), - } - return pat - - -def make_straight_wire(layer: layer_t, width: float, ptype: str, length: float) -> Pattern: - """ - Generate a straight wire with ports along either end (x=0 and x=length). - - Every waveguide will be single-use, so we'll need to create lots of (mostly unique) - `Pattern`s, and this function will get called very often. - """ - pat = Pattern() - pat.rect(layer=layer, xmin=0, xmax=length, yctr=0, ly=width) - pat.ports = { - 'input': Port(offset=(0, 0), rotation=0, ptype=ptype), - 'output': Port(offset=(length, 0), rotation=pi, ptype=ptype), - } - return pat - - -def map_layer(layer: layer_t) -> layer_t: - """ - Map from a strings to GDS layer numbers - """ - layer_mapping = { - 'M1': (10, 0), - 'M2': (20, 0), - 'V1': (30, 0), - } - return layer_mapping.get(layer, layer) - - -# -# Now we can start building up our library (collection of static cells) and pathing tools. -# -# If any of the operations below are confusing, you can cross-reference against the `RenderPather` -# tutorial, which handles some things more explicitly (e.g. via placement) and simplifies others -# (e.g. geometry definition). -# -def main() -> None: - # Build some patterns (static cells) using the above functions and store them in a library - library = Library() - library['pad'] = make_pad() - library['m1_bend'] = make_bend(layer='M1', ptype='m1wire', width=M1_WIDTH) - library['m2_bend'] = make_bend(layer='M2', ptype='m2wire', width=M2_WIDTH) - library['v1_via'] = make_via( - layer_top='M2', - layer_via='V1', - layer_bot='M1', - width_top=M2_WIDTH, - width_via=V1_WIDTH, - width_bot=M1_WIDTH, - ptype_bot='m1wire', - ptype_top='m2wire', - ) - - # - # Now, define two tools. - # M1_tool will route on M1, using wires with M1_WIDTH - # M2_tool will route on M2, using wires with M2_WIDTH - # Both tools are able to automatically transition from the other wire type (with a via) - # - # Note that while we use BasicTool for this tutorial, you can define your own `Tool` - # with arbitrary logic inside -- e.g. with single-use bends, complex transition rules, - # transmission line geometry, or other features. - # - M1_tool = BasicTool( - straight = ( - # First, we need a function which takes in a length and spits out an M1 wire - lambda length: make_straight_wire(layer='M1', ptype='m1wire', width=M1_WIDTH, length=length), - 'input', # When we get a pattern from make_straight_wire, use the port named 'input' as the input - 'output', # and use the port named 'output' as the output - ), - bend = ( - library.abstract('m1_bend'), # When we need a bend, we'll reference the pattern we generated earlier - 'input', # To orient it clockwise, use the port named 'input' as the input - 'output', # and 'output' as the output - ), - transitions = { # We can automate transitions for different (normally incompatible) port types - 'm2wire': ( # For example, when we're attaching to a port with type 'm2wire' - library.abstract('v1_via'), # we can place a V1 via - 'top', # using the port named 'top' as the input (i.e. the M2 side of the via) - 'bottom', # and using the port named 'bottom' as the output - ), - }, - default_out_ptype = 'm1wire', # Unless otherwise requested, we'll default to trying to stay on M1 - ) - - M2_tool = BasicTool( - straight = ( - # Again, we use make_straight_wire, but this time we set parameters for M2 - lambda length: make_straight_wire(layer='M2', ptype='m2wire', width=M2_WIDTH, length=length), - 'input', - 'output', - ), - bend = ( - library.abstract('m2_bend'), # and we use an M2 bend - 'input', - 'output', - ), - transitions = { - 'm1wire': ( - library.abstract('v1_via'), # We still use the same via, - 'bottom', # but the input port is now 'bottom' - 'top', # and the output port is now 'top' - ), - }, - default_out_ptype = 'm2wire', # We default to trying to stay on M2 - ) - - # - # Create a new pather which writes to `library` and uses `M2_tool` as its default tool. - # Then, place some pads and start routing wires! - # - pather = Pather(library, tools=M2_tool) - - # Place two pads, and define their ports as 'VCC' and 'GND' - pather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'}) - pather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'}) - # Add some labels to make the pads easier to distinguish - pather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3)) - pather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) - - # Path VCC forward (in this case south) and turn clockwise 90 degrees (ccw=False) - # The total distance forward (including the bend's forward component) must be 6um - pather.path('VCC', ccw=False, length=6_000) - - # Now path VCC to x=0. This time, don't include any bend (ccw=None). - # Note that if we tried y=0 here, we would get an error since the VCC port is facing in the x-direction. - pather.path_to('VCC', ccw=None, x=0) - - # Path GND forward by 5um, turning clockwise 90 degrees. - # This time we use shorthand (bool(0) == False) and omit the parameter labels - # Note that although ccw=0 is equivalent to ccw=False, ccw=None is not! - pather.path('GND', 0, 5_000) - - # This time, path GND until it matches the current x-coordinate of VCC. Don't place a bend. - pather.path_to('GND', None, x=pather['VCC'].offset[0]) - - # Now, start using M1_tool for GND. - # Since we have defined an M2-to-M1 transition for BasicPather, we don't need to place one ourselves. - # If we wanted to place our via manually, we could add `pather.plug('m1_via', {'GND': 'top'})` here - # and achieve the same result without having to define any transitions in M1_tool. - # Note that even though we have changed the tool used for GND, the via doesn't get placed until - # the next time we draw a path on GND (the pather.mpath() statement below). - pather.retool(M1_tool, keys=['GND']) - - # Bundle together GND and VCC, and path the bundle forward and counterclockwise. - # Pick the distance so that the leading/outermost wire (in this case GND) ends up at x=-10_000. - # Other wires in the bundle (in this case VCC) should be spaced at 5_000 pitch (so VCC ends up at x=-5_000) - # - # Since we recently retooled GND, its path starts with a via down to M1 (included in the distance - # calculation), and its straight segment and bend will be drawn using M1 while VCC's are drawn with M2. - pather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000) - - # Now use M1_tool as the default tool for all ports/signals. - # Since VCC does not have an explicitly assigned tool, it will now transition down to M1. - pather.retool(M1_tool) - - # Path the GND + VCC bundle forward and counterclockwise by 90 degrees. - # The total extension (travel distance along the forward direction) for the longest segment (in - # this case the segment being added to GND) should be exactly 50um. - # After turning, the wire pitch should be reduced only 1.2um. - pather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200) - - # Make a U-turn with the bundle and expand back out to 4.5um wire pitch. - # Here, emin specifies the travel distance for the shortest segment. For the first mpath() call - # that applies to VCC, and for teh second call, that applies to GND; the relative lengths of the - # segments depend on their starting positions and their ordering within the bundle. - pather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200) - pather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500) - - # Now, set the default tool back to M2_tool. Note that GND remains on M1 since it has been - # explicitly assigned a tool. We could `del pather.tools['GND']` to force it to use the default. - pather.retool(M2_tool) - - # Now path both ports to x=-28_000. - # When ccw is not None, xmin constrains the trailing/innermost port to stop at the target x coordinate, - # However, with ccw=None, all ports stop at the same coordinate, and so specifying xmin= or xmax= is - # equivalent. - pather.mpath(['GND', 'VCC'], None, xmin=-28_000) - - # Further extend VCC out to x=-50_000, and specify that we would like to get an output on M1. - # This results in a via at the end of the wire (instead of having one at the start like we got - # when using pather.retool(). - pather.path_to('VCC', None, -50_000, out_ptype='m1wire') - - # Now extend GND out to x=-50_000, using M2 for a portion of the path. - # We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice. - with pather.toolctx(M2_tool, keys=['GND']): - pather.path_to('GND', None, -40_000) - pather.path_to('GND', None, -50_000) - - # Save the pather's pattern into our library - library['Pather_and_BasicTool'] = pather.pattern - - # Convert from text-based layers to numeric layers for GDS, and output the file - library.map_layers(map_layer) - writefile(library, 'pather.gds', **GDS_OPTS) - - -if __name__ == '__main__': - main() diff --git a/examples/tutorial/pcgen.py b/examples/tutorial/pcgen.py index 023079c..aebcc3f 100644 --- a/examples/tutorial/pcgen.py +++ b/examples/tutorial/pcgen.py @@ -2,7 +2,7 @@ Routines for creating normalized 2D lattices and common photonic crystal cavity designs. """ -from collection.abc import Sequence +from typing import Sequence, Tuple import numpy from numpy.typing import ArrayLike, NDArray @@ -29,11 +29,8 @@ def triangular_lattice( Returns: `[[x0, y0], [x1, 1], ...]` denoting lattice sites. """ - sx, sy = numpy.meshgrid( - numpy.arange(dims[0], dtype=float), - numpy.arange(dims[1], dtype=float), - indexing='ij', - ) + sx, sy = numpy.meshgrid(numpy.arange(dims[0], dtype=float), + numpy.arange(dims[1], dtype=float), indexing='ij') sx[sy % 2 == 1] += 0.5 sy *= numpy.sqrt(3) / 2 @@ -233,8 +230,8 @@ def ln_shift_defect( # Shift holes # Expand shifts as necessary - tmp_a = numpy.asarray(shifts_a) - tmp_r = numpy.asarray(shifts_r) + tmp_a = numpy.array(shifts_a) + tmp_r = numpy.array(shifts_r) n_shifted = max(tmp_a.size, tmp_r.size) shifts_a = numpy.ones(n_shifted) diff --git a/examples/tutorial/renderpather.py b/examples/tutorial/renderpather.py deleted file mode 100644 index cb002f3..0000000 --- a/examples/tutorial/renderpather.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Manual wire routing tutorial: RenderPather an PathTool -""" -from collections.abc import Callable -from masque import RenderPather, Library, Pattern, Port, layer_t, map_layers -from masque.builder.tools import PathTool -from masque.file.gdsii import writefile - -from basic_shapes import GDS_OPTS -from pather import M1_WIDTH, V1_WIDTH, M2_WIDTH, map_layer, make_pad, make_via - - -def main() -> None: - # - # To illustrate the advantages of using `RenderPather`, we use `PathTool` instead - # of `BasicTool`. `PathTool` lacks some sophistication (e.g. no automatic transitions) - # but when used with `RenderPather`, it can consolidate multiple routing steps into - # a single `Path` shape. - # - # We'll try to nearly replicate the layout from the `Pather` tutorial; see `pather.py` - # for more detailed descriptions of the individual pathing steps. - # - - # First, we make a library and generate some of the same patterns as in the pather tutorial - library = Library() - library['pad'] = make_pad() - library['v1_via'] = make_via( - layer_top='M2', - layer_via='V1', - layer_bot='M1', - width_top=M2_WIDTH, - width_via=V1_WIDTH, - width_bot=M1_WIDTH, - ptype_bot='m1wire', - ptype_top='m2wire', - ) - - # `PathTool` is more limited than `BasicTool`. It only generates one type of shape - # (`Path`), so it only needs to know what layer to draw on, what width to draw with, - # and what port type to present. - M1_ptool = PathTool(layer='M1', width=M1_WIDTH, ptype='m1wire') - M2_ptool = PathTool(layer='M2', width=M2_WIDTH, ptype='m2wire') - rpather = RenderPather(tools=M2_ptool, library=library) - - # As in the pather tutorial, we make soem pads and labels... - rpather.place('pad', offset=(18_000, 30_000), port_map={'wire_port': 'VCC'}) - rpather.place('pad', offset=(18_000, 60_000), port_map={'wire_port': 'GND'}) - rpather.pattern.label(layer='M2', string='VCC', offset=(18e3, 30e3)) - rpather.pattern.label(layer='M2', string='GND', offset=(18e3, 60e3)) - - # ...and start routing the signals. - rpather.path('VCC', ccw=False, length=6_000) - rpather.path_to('VCC', ccw=None, x=0) - rpather.path('GND', 0, 5_000) - rpather.path_to('GND', None, x=rpather['VCC'].offset[0]) - - # `PathTool` doesn't know how to transition betwen metal layers, so we have to - # `plug` the via into the GND wire ourselves. - rpather.plug('v1_via', {'GND': 'top'}) - rpather.retool(M1_ptool, keys=['GND']) - rpather.mpath(['GND', 'VCC'], ccw=True, xmax=-10_000, spacing=5_000) - - # Same thing on the VCC wire when it goes down to M1. - rpather.plug('v1_via', {'VCC': 'top'}) - rpather.retool(M1_ptool) - rpather.mpath(['GND', 'VCC'], ccw=True, emax=50_000, spacing=1_200) - rpather.mpath(['GND', 'VCC'], ccw=False, emin=1_000, spacing=1_200) - rpather.mpath(['GND', 'VCC'], ccw=False, emin=2_000, spacing=4_500) - - # And again when VCC goes back up to M2. - rpather.plug('v1_via', {'VCC': 'bottom'}) - rpather.retool(M2_ptool) - rpather.mpath(['GND', 'VCC'], None, xmin=-28_000) - - # Finally, since PathTool has no conception of transitions, we can't - # just ask it to transition to an 'm1wire' port at the end of the final VCC segment. - # Instead, we have to calculate the via size ourselves, and adjust the final position - # to account for it. - via_size = abs( - library['v1_via'].ports['top'].offset[0] - - library['v1_via'].ports['bottom'].offset[0] - ) - rpather.path_to('VCC', None, -50_000 + via_size) - rpather.plug('v1_via', {'VCC': 'top'}) - - rpather.render() - library['RenderPather_and_PathTool'] = rpather.pattern - - - # Convert from text-based layers to numeric layers for GDS, and output the file - library.map_layers(map_layer) - writefile(library, 'render_pather.gds', **GDS_OPTS) - - -if __name__ == '__main__': - main() diff --git a/masque/__init__.py b/masque/__init__.py index 4ad7e69..7881bdb 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -1,16 +1,16 @@ """ masque 2D CAD library - masque is an attempt to make a relatively compact library for designing lithography + masque is an attempt to make a relatively small library for designing lithography masks. The general idea is to implement something resembling the GDSII and OASIS file-formats, - but with some additional vectorized element types (eg. ellipses, not just polygons), and the - ability to interface with multiple file formats. + but with some additional vectorized element types (eg. ellipses, not just polygons), better + support for E-beam doses, and the ability to interface with multiple file formats. `Pattern` is a basic object containing a 2D lithography mask, composed of a list of `Shape` objects, a list of `Label` objects, and a list of references to other `Patterns` (using - `Ref`). + `SubPattern`). - `Ref` provides basic support for nesting `Pattern` objects within each other, by adding + `SubPattern` provides basic support for nesting `Pattern` objects within each other, by adding offset, rotation, scaling, repetition, and other such properties to a Pattern reference. Note that the methods for these classes try to avoid copying wherever possible, so unless @@ -20,77 +20,24 @@ NOTES ON INTERNALS ========================== - Many of `masque`'s classes make use of `__slots__` to make them faster / smaller. - Since `__slots__` doesn't play well with multiple inheritance, often they are left - empty for superclasses and it is the subclass's responsibility to set them correctly. - - File I/O submodules are not imported by `masque.file` to avoid creating hard dependencies - on external file-format reader/writers -- Try to accept the broadest-possible inputs: e.g., don't demand an `ILibraryView` if you - can accept a `Mapping[str, Pattern]` and wrap it in a `LibraryView` internally. + Since `__slots__` doesn't play well with multiple inheritance, the `masque.utils.AutoSlots` + metaclass is used to auto-generate slots based on superclass type annotations. + - File I/O submodules are imported by `masque.file` to avoid creating hard dependencies on + external file-format reader/writers + - Pattern locking/unlocking is quite slow for large hierarchies. + """ -from .utils import ( - layer_t as layer_t, - annotations_t as annotations_t, - SupportsBool as SupportsBool, - ) -from .error import ( - MasqueError as MasqueError, - PatternError as PatternError, - LibraryError as LibraryError, - BuildError as BuildError, - ) -from .shapes import ( - Shape as Shape, - Polygon as Polygon, - Path as Path, - Circle as Circle, - Arc as Arc, - Ellipse as Ellipse, - ) -from .label import Label as Label -from .ref import Ref as Ref -from .pattern import ( - Pattern as Pattern, - map_layers as map_layers, - map_targets as map_targets, - chain_elements as chain_elements, - ) - -from .library import ( - ILibraryView as ILibraryView, - ILibrary as ILibrary, - LibraryView as LibraryView, - Library as Library, - LazyLibrary as LazyLibrary, - AbstractView as AbstractView, - TreeView as TreeView, - Tree as Tree, - ) -from .ports import ( - Port as Port, - PortList as PortList, - ) -from .abstract import Abstract as Abstract -from .builder import ( - Builder as Builder, - Tool as Tool, - Pather as Pather, - RenderPather as RenderPather, - RenderStep as RenderStep, - SimpleTool as SimpleTool, - AutoTool as AutoTool, - PathTool as PathTool, - PortPather as PortPather, - ) -from .utils import ( - ports2data as ports2data, - oneshot as oneshot, - R90 as R90, - R180 as R180, - ) +from .error import PatternError, PatternLockedError +from .shapes import Shape +from .label import Label +from .subpattern import SubPattern +from .pattern import Pattern +from .utils import layer_t, annotations_t +from .library import Library, DeviceLibrary __author__ = 'Jan Petykiewicz' -__version__ = '3.4' +__version__ = '2.7' version = __version__ # legacy diff --git a/masque/abstract.py b/masque/abstract.py deleted file mode 100644 index 7135eba..0000000 --- a/masque/abstract.py +++ /dev/null @@ -1,216 +0,0 @@ -from typing import Self -import copy -import logging - -import numpy -from numpy.typing import ArrayLike - -from .ref import Ref -from .ports import PortList, Port -from .utils import rotation_matrix_2d - -#if TYPE_CHECKING: -# from .builder import Builder, Tool -# from .library import ILibrary - - -logger = logging.getLogger(__name__) - - -class Abstract(PortList): - """ - An `Abstract` is a container for a name and associated ports. - - When snapping a sub-component to an existing pattern, only the name (not contained - in a `Pattern` object) and port info is needed, and not the geometry itself. - """ - # Alternate design option: do we want to store a Ref instead of just a name? then we can translate/rotate/mirror... - __slots__ = ('name', '_ports') - - name: str - """ Name of the pattern this device references """ - - _ports: dict[str, Port] - """ Uniquely-named ports which can be used to instances together""" - - @property - def ports(self) -> dict[str, Port]: - return self._ports - - @ports.setter - def ports(self, value: dict[str, Port]) -> None: - self._ports = value - - def __init__( - self, - name: str, - ports: dict[str, Port], - ) -> None: - self.name = name - self.ports = copy.deepcopy(ports) - - def __repr__(self) -> str: - s = f' Self: - """ - Translates all ports by the given offset. - - Args: - offset: (x, y) to translate by - - Returns: - self - """ - for port in self.ports.values(): - port.translate(offset) - return self - - def scale_by(self, c: float) -> Self: - """ - Scale this Abstract by the given value - (all port offsets are scaled) - - Args: - c: factor to scale by - - Returns: - self - """ - for port in self.ports.values(): - port.offset *= c - return self - - def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: - """ - Rotate the Abstract around a pivot point. - - Args: - pivot: (x, y) location to rotate around - rotation: Angle to rotate by (counter-clockwise, radians) - - Returns: - self - """ - pivot = numpy.asarray(pivot, dtype=float) - self.translate_ports(-pivot) - self.rotate_ports(rotation) - self.rotate_port_offsets(rotation) - self.translate_ports(+pivot) - return self - - def rotate_port_offsets(self, rotation: float) -> Self: - """ - Rotate the offsets of all ports around (0, 0) - - Args: - rotation: Angle to rotate by (counter-clockwise, radians) - - Returns: - self - """ - for port in self.ports.values(): - port.offset = rotation_matrix_2d(rotation) @ port.offset - return self - - def rotate_ports(self, rotation: float) -> Self: - """ - Rotate each port around its offset (i.e. in place) - - Args: - rotation: Angle to rotate by (counter-clockwise, radians) - - Returns: - self - """ - for port in self.ports.values(): - port.rotate(rotation) - return self - - def mirror_port_offsets(self, across_axis: int = 0) -> Self: - """ - Mirror the offsets of all shapes, labels, and refs across an axis - - Args: - across_axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) - - Returns: - self - """ - for port in self.ports.values(): - port.offset[across_axis - 1] *= -1 - return self - - def mirror_ports(self, across_axis: int = 0) -> Self: - """ - Mirror each port's rotation across an axis, relative to its - offset - - Args: - across_axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) - - Returns: - self - """ - for port in self.ports.values(): - port.mirror(across_axis) - return self - - def mirror(self, across_axis: int = 0) -> Self: - """ - Mirror the Pattern across an axis - - Args: - axis: Axis to mirror across - (0: mirror across x axis, 1: mirror across y axis) - - Returns: - self - """ - self.mirror_ports(across_axis) - self.mirror_port_offsets(across_axis) - return self - - def apply_ref_transform(self, ref: Ref) -> Self: - """ - Apply the transform from a `Ref` to the ports of this `Abstract`. - This changes the port locations to where they would be in the Ref's parent pattern. - - Args: - ref: The ref whose transform should be applied. - - Returns: - self - """ - if ref.mirrored: - self.mirror() - self.rotate_ports(ref.rotation) - self.rotate_port_offsets(ref.rotation) - self.translate_ports(ref.offset) - return self - - def undo_ref_transform(self, ref: Ref) -> Self: - """ - Apply the inverse transform from a `Ref` to the ports of this `Abstract`. - This changes the port locations to where they would be in the Ref's target (from the parent). - - Args: - ref: The ref whose (inverse) transform should be applied. - - Returns: - self - - # TODO test undo_ref_transform - """ - self.translate_ports(-ref.offset) - self.rotate_port_offsets(-ref.rotation) - self.rotate_ports(-ref.rotation) - if ref.mirrored: - self.mirror(0) - return self diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py index 2fd00a4..0c083b7 100644 --- a/masque/builder/__init__.py +++ b/masque/builder/__init__.py @@ -1,12 +1,3 @@ -from .builder import Builder as Builder -from .pather import Pather as Pather -from .renderpather import RenderPather as RenderPather -from .pather_mixin import PortPather as PortPather -from .utils import ell as ell -from .tools import ( - Tool as Tool, - RenderStep as RenderStep, - SimpleTool as SimpleTool, - AutoTool as AutoTool, - PathTool as PathTool, - ) +from .devices import Port, Device +from .utils import ell +from .tools import Tool diff --git a/masque/builder/builder.py b/masque/builder/builder.py deleted file mode 100644 index 1b534b5..0000000 --- a/masque/builder/builder.py +++ /dev/null @@ -1,448 +0,0 @@ -""" -Simplified Pattern assembly (`Builder`) -""" -from typing import Self -from collections.abc import Iterable, Sequence, Mapping -import copy -import logging -from functools import wraps - -from numpy.typing import ArrayLike - -from ..pattern import Pattern -from ..library import ILibrary, TreeView -from ..error import BuildError -from ..ports import PortList, Port -from ..abstract import Abstract - - -logger = logging.getLogger(__name__) - - -class Builder(PortList): - """ - A `Builder` is a helper object used for snapping together multiple - lower-level patterns at their `Port`s. - - The `Builder` mostly just holds context, in the form of a `Library`, - in addition to its underlying pattern. This simplifies some calls - to `plug` and `place`, by making the library implicit. - - `Builder` can also be `set_dead()`, at which point further calls to `plug()` - and `place()` are ignored (intended for debugging). - - - Examples: Creating a Builder - =========================== - - `Builder(library, ports={'A': port_a, 'C': port_c}, name='mypat')` makes - an empty pattern, adds the given ports, and places it into `library` - under the name `'mypat'`. - - - `Builder(library)` makes an empty pattern with no ports. The pattern - is not added into `library` and must later be added with e.g. - `library['mypat'] = builder.pattern` - - - `Builder(library, pattern=pattern, name='mypat')` uses an existing - pattern (including its ports) and sets `library['mypat'] = pattern`. - - - `Builder.interface(other_pat, port_map=['A', 'B'], library=library)` - makes a new (empty) pattern, copies over ports 'A' and 'B' from - `other_pat`, and creates additional ports 'in_A' and 'in_B' facing - in the opposite directions. This can be used to build a device which - can plug into `other_pat` (using the 'in_*' ports) but which does not - itself include `other_pat` as a subcomponent. - - - `Builder.interface(other_builder, ...)` does the same thing as - `Builder.interface(other_builder.pattern, ...)` but also uses - `other_builder.library` as its library by default. - - - Examples: Adding to a pattern - ============================= - - `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})` - instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B' - of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports - are removed and any unconnected ports from `subdevice` are added to - `my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'. - - - `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' - of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, - argument is provided, and the `thru` argument is not explicitly - set to `False`, the unconnected port of `wire` is automatically renamed to - 'myport'. This allows easy extension of existing ports without changing - their names or having to provide `map_out` each time `plug` is called. - - - `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})` - instantiates `pad` at the specified (x, y) offset and with the specified - rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is - renamed to 'gnd' so that further routing can use this signal or net name - rather than the port name on the original `pad` device. - """ - __slots__ = ('pattern', 'library', '_dead') - - pattern: Pattern - """ Layout of this device """ - - library: ILibrary - """ - Library from which patterns should be referenced - """ - - _dead: bool - """ If True, plug()/place() are skipped (for debugging)""" - - @property - def ports(self) -> dict[str, Port]: - return self.pattern.ports - - @ports.setter - def ports(self, value: dict[str, Port]) -> None: - self.pattern.ports = value - - def __init__( - self, - library: ILibrary, - *, - pattern: Pattern | None = None, - ports: str | Mapping[str, Port] | None = None, - name: str | None = None, - ) -> None: - """ - Args: - library: The library from which referenced patterns will be taken - pattern: The pattern which will be modified by subsequent operations. - If `None` (default), a new pattern is created. - ports: Allows specifying the initial set of ports, if `pattern` does - not already have any ports (or is not provided). May be a string, - in which case it is interpreted as a name in `library`. - Default `None` (no ports). - name: If specified, `library[name]` is set to `self.pattern`. - """ - self._dead = False - self.library = library - if pattern is not None: - self.pattern = pattern - else: - self.pattern = Pattern() - - if ports is not None: - if self.pattern.ports: - raise BuildError('Ports supplied for pattern with pre-existing ports!') - if isinstance(ports, str): - ports = library.abstract(ports).ports - - self.pattern.ports.update(copy.deepcopy(dict(ports))) - - if name is not None: - library[name] = self.pattern - - @classmethod - def interface( - cls: type['Builder'], - source: PortList | Mapping[str, Port] | str, - *, - library: ILibrary | None = None, - in_prefix: str = 'in_', - out_prefix: str = '', - port_map: dict[str, str] | Sequence[str] | None = None, - name: str | None = None, - ) -> 'Builder': - """ - Wrapper for `Pattern.interface()`, which returns a Builder instead. - - Args: - source: A collection of ports (e.g. Pattern, Builder, or dict) - from which to create the interface. May be a pattern name if - `library` is provided. - library: Library from which existing patterns should be referenced, - and to which the new one should be added (if named). If not provided, - `source.library` must exist and will be used. - in_prefix: Prepended to port names for newly-created ports with - reversed directions compared to the current device. - out_prefix: Prepended to port names for ports which are directly - copied from the current device. - port_map: Specification for ports to copy into the new device: - - If `None`, all ports are copied. - - If a sequence, only the listed ports are copied - - If a mapping, the listed ports (keys) are copied and - renamed (to the values). - - Returns: - The new builder, with an empty pattern and 2x as many ports as - listed in port_map. - - Raises: - `PortError` if `port_map` contains port names not present in the - current device. - `PortError` if applying the prefixes results in duplicate port - names. - """ - if library is None: - if hasattr(source, 'library') and isinstance(source.library, ILibrary): - library = source.library - else: - raise BuildError('No library was given, and `source.library` does not have one either.') - - if isinstance(source, str): - source = library.abstract(source).ports - - pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map) - new = Builder(library=library, pattern=pat, name=name) - return new - - @wraps(Pattern.label) - def label(self, *args, **kwargs) -> Self: - self.pattern.label(*args, **kwargs) - return self - - @wraps(Pattern.ref) - def ref(self, *args, **kwargs) -> Self: - self.pattern.ref(*args, **kwargs) - return self - - @wraps(Pattern.polygon) - def polygon(self, *args, **kwargs) -> Self: - self.pattern.polygon(*args, **kwargs) - return self - - @wraps(Pattern.rect) - def rect(self, *args, **kwargs) -> Self: - self.pattern.rect(*args, **kwargs) - return self - - # Note: We're a superclass of `Pather`, where path() means something different, - # so we shouldn't wrap Pattern.path() - #@wraps(Pattern.path) - #def path(self, *args, **kwargs) -> Self: - # self.pattern.path(*args, **kwargs) - # return self - - def plug( - self, - other: Abstract | str | Pattern | TreeView, - map_in: dict[str, str], - map_out: dict[str, str | None] | None = None, - *, - mirrored: bool = False, - thru: bool | str = True, - set_rotation: bool | None = None, - append: bool = False, - ok_connections: Iterable[tuple[str, str]] = (), - ) -> Self: - """ - Wrapper around `Pattern.plug` which allows a string for `other`. - - The `Builder`'s library is used to dereference the string (or `Abstract`, if - one is passed with `append=True`). If a `TreeView` is passed, it is first - added into `self.library`. - - Args: - other: An `Abstract`, string, `Pattern`, or `TreeView` describing the - device to be instatiated. If it is a `TreeView`, it is first - added into `self.library`, after which the topcell is plugged; - an equivalent statement is `self.plug(self.library << other, ...)`. - map_in: dict of `{'self_port': 'other_port'}` mappings, specifying - port connections between the two devices. - map_out: dict of `{'old_name': 'new_name'}` mappings, specifying - new names for ports in `other`. - mirrored: Enables mirroring `other` across the x axis prior to - connecting any ports. - thru: If map_in specifies only a single port, `thru` provides a mechainsm - to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, - - If True (default), and `other` has only two ports total, and map_out - doesn't specify a name for the other port, its name is set to the key - in `map_in`, i.e. 'myport'. - - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). - An error is raised if that entry already exists. - - This makes it easy to extend a pattern with simple 2-port devices - (e.g. wires) without providing `map_out` each time `plug` is - called. See "Examples" above for more info. Default `True`. - set_rotation: If the necessary rotation cannot be determined from - the ports being connected (i.e. all pairs have at least one - port with `rotation=None`), `set_rotation` must be provided - to indicate how much `other` should be rotated. Otherwise, - `set_rotation` must remain `None`. - append: If `True`, `other` is appended instead of being referenced. - Note that this does not flatten `other`, so its refs will still - be refs (now inside `self`). - ok_connections: Set of "allowed" ptype combinations. Identical - ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will emit a - warning. Order is ignored, i.e. `(a, b)` is equivalent to - `(b, a)`. - - Returns: - self - - Raises: - `PortError` if any ports specified in `map_in` or `map_out` do not - exist in `self.ports` or `other_names`. - `PortError` if there are any duplicate names after `map_in` and `map_out` - are applied. - `PortError` if the specified port mapping is not achieveable (the ports - do not line up) - """ - if self._dead: - logger.error('Skipping plug() since device is dead') - return self - - if not isinstance(other, str | Abstract | Pattern): - # We got a Tree; add it into self.library and grab an Abstract for it - other = self.library << other - - if isinstance(other, str): - other = self.library.abstract(other) - if append and isinstance(other, Abstract): - other = self.library[other.name] - - self.pattern.plug( - other = other, - map_in = map_in, - map_out = map_out, - mirrored = mirrored, - thru = thru, - set_rotation = set_rotation, - append = append, - ok_connections = ok_connections, - ) - return self - - def place( - self, - other: Abstract | str | Pattern | TreeView, - *, - offset: ArrayLike = (0, 0), - rotation: float = 0, - pivot: ArrayLike = (0, 0), - mirrored: bool = False, - port_map: dict[str, str | None] | None = None, - skip_port_check: bool = False, - append: bool = False, - ) -> Self: - """ - Wrapper around `Pattern.place` which allows a string or `TreeView` for `other`. - - The `Builder`'s library is used to dereference the string (or `Abstract`, if - one is passed with `append=True`). If a `TreeView` is passed, it is first - added into `self.library`. - - Args: - other: An `Abstract`, string, `Pattern`, or `TreeView` describing the - device to be instatiated. If it is a `TreeView`, it is first - added into `self.library`, after which the topcell is plugged; - an equivalent statement is `self.plug(self.library << other, ...)`. - offset: Offset at which to place the instance. Default (0, 0). - rotation: Rotation applied to the instance before placement. Default 0. - pivot: Rotation is applied around this pivot point (default (0, 0)). - Rotation is applied prior to translation (`offset`). - mirrored: Whether theinstance should be mirrored across the x axis. - Mirroring is applied before translation and rotation. - port_map: dict of `{'old_name': 'new_name'}` mappings, specifying - new names for ports in the instantiated device. New names can be - `None`, which will delete those ports. - skip_port_check: Can be used to skip the internal call to `check_ports`, - in case it has already been performed elsewhere. - append: If `True`, `other` is appended instead of being referenced. - Note that this does not flatten `other`, so its refs will still - be refs (now inside `self`). - - Returns: - self - - Raises: - `PortError` if any ports specified in `map_in` or `map_out` do not - exist in `self.ports` or `other.ports`. - `PortError` if there are any duplicate names after `map_in` and `map_out` - are applied. - """ - if self._dead: - logger.error('Skipping place() since device is dead') - return self - - if not isinstance(other, str | Abstract | Pattern): - # We got a Tree; add it into self.library and grab an Abstract for it - other = self.library << other - - if isinstance(other, str): - other = self.library.abstract(other) - if append and isinstance(other, Abstract): - other = self.library[other.name] - - self.pattern.place( - other = other, - offset = offset, - rotation = rotation, - pivot = pivot, - mirrored = mirrored, - port_map = port_map, - skip_port_check = skip_port_check, - append = append, - ) - return self - - def translate(self, offset: ArrayLike) -> Self: - """ - Translate the pattern and all ports. - - Args: - offset: (x, y) distance to translate by - - Returns: - self - """ - self.pattern.translate_elements(offset) - return self - - def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: - """ - Rotate the pattern and all ports. - - Args: - angle: angle (radians, counterclockwise) to rotate by - pivot: location to rotate around - - Returns: - self - """ - self.pattern.rotate_around(pivot, angle) - for port in self.ports.values(): - port.rotate_around(pivot, angle) - return self - - def mirror(self, axis: int = 0) -> Self: - """ - Mirror the pattern and all ports across the specified axis. - - Args: - axis: Axis to mirror across (x=0, y=1) - - Returns: - self - """ - self.pattern.mirror(axis) - return self - - def set_dead(self) -> Self: - """ - Disallows further changes through `plug()` or `place()`. - This is meant for debugging: - ``` - dev.plug(a, ...) - dev.set_dead() # added for debug purposes - dev.plug(b, ...) # usually raises an error, but now skipped - dev.plug(c, ...) # also skipped - dev.pattern.visualize() # shows the device as of the set_dead() call - ``` - - Returns: - self - """ - self._dead = True - return self - - def __repr__(self) -> str: - s = f'' - return s - - diff --git a/masque/builder/devices.py b/masque/builder/devices.py new file mode 100644 index 0000000..b681f33 --- /dev/null +++ b/masque/builder/devices.py @@ -0,0 +1,892 @@ +from typing import Dict, Iterable, List, Tuple, Union, TypeVar, Any, Iterator, Optional, Sequence +from typing import overload, KeysView, ValuesView +import copy +import warnings +import traceback +import logging +from collections import Counter + +import numpy +from numpy import pi +from numpy.typing import ArrayLike, NDArray + +from ..pattern import Pattern +from ..subpattern import SubPattern +from ..traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable +from ..utils import AutoSlots, rotation_matrix_2d +from ..error import DeviceError +from .tools import Tool +from .utils import ell + + +logger = logging.getLogger(__name__) + + +P = TypeVar('P', bound='Port') +D = TypeVar('D', bound='Device') +O = TypeVar('O', bound='Device') + + +class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable, metaclass=AutoSlots): + """ + A point at which a `Device` can be snapped to another `Device`. + + Each port has an `offset` ((x, y) position) and may also have a + `rotation` (orientation) and a `ptype` (port type). + + The `rotation` is an angle, in radians, measured counterclockwise + from the +x axis, pointing inwards into the device which owns the port. + The rotation may be set to `None`, indicating that any orientation is + allowed (e.g. for a DC electrical port). It is stored modulo 2pi. + + The `ptype` is an arbitrary string, default of `unk` (unknown). + """ + __slots__ = ('ptype', '_rotation') + + _rotation: Optional[float] + """ radians counterclockwise from +x, pointing into device body. + Can be `None` to signify undirected port """ + + ptype: str + """ Port types must match to be plugged together if both are non-zero """ + + def __init__( + self, + offset: ArrayLike, + rotation: Optional[float], + ptype: str = 'unk', + ) -> None: + self.offset = offset + self.rotation = rotation + self.ptype = ptype + + @property + def rotation(self) -> Optional[float]: + """ Rotation, radians counterclockwise, pointing into device body. Can be None. """ + return self._rotation + + @rotation.setter + def rotation(self, val: float) -> None: + if val is None: + self._rotation = None + else: + if not numpy.size(val) == 1: + raise DeviceError('Rotation must be a scalar') + self._rotation = val % (2 * pi) + + def get_bounds(self): + return numpy.vstack((self.offset, self.offset)) + + def set_ptype(self: P, ptype: str) -> P: + """ Chainable setter for `ptype` """ + self.ptype = ptype + return self + + def mirror(self: P, axis: int) -> P: + self.offset[1 - axis] *= -1 + if self.rotation is not None: + self.rotation *= -1 + self.rotation += axis * pi + return self + + def rotate(self: P, rotation: float) -> P: + if self.rotation is not None: + self.rotation += rotation + return self + + def set_rotation(self: P, rotation: Optional[float]) -> P: + self.rotation = rotation + return self + + def __repr__(self) -> str: + if self.rotation is None: + rot = 'any' + else: + rot = str(numpy.rad2deg(self.rotation)) + return f'<{self.offset}, {rot}, [{self.ptype}]>' + + +class Device(Copyable, Mirrorable): + """ + A `Device` is a combination of a `Pattern` with a set of named `Port`s + which can be used to "snap" devices together to make complex layouts. + + `Device`s can be as simple as one or two ports (e.g. an electrical pad + or wire), but can also be used to build and represent a large routed + layout (e.g. a logical block with multiple I/O connections or even a + full chip). + + For convenience, ports can be read out using square brackets: + - `device['A'] == Port((0, 0), 0)` + - `device[['A', 'B']] == {'A': Port((0, 0), 0), 'B': Port((0, 0), pi)}` + + Examples: Creating a Device + =========================== + - `Device(pattern, ports={'A': port_a, 'C': port_c})` uses an existing + pattern and defines some ports. + + - `Device(name='my_dev_name', ports=None)` makes a new empty pattern with + default ports ('A' and 'B', in opposite directions, at (0, 0)). + + - `my_device.build('my_layout')` makes a new pattern and instantiates + `my_device` in it with offset (0, 0) as a base for further building. + + - `my_device.as_interface('my_component', port_map=['A', 'B'])` makes a new + (empty) pattern, copies over ports 'A' and 'B' from `my_device`, and + creates additional ports 'in_A' and 'in_B' facing in the opposite + directions. This can be used to build a device which can plug into + `my_device` (using the 'in_*' ports) but which does not itself include + `my_device` as a subcomponent. + + Examples: Adding to a Device + ============================ + - `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})` + instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B' + of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports + are removed and any unconnected ports from `subdevice` are added to + `my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'. + + - `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' + of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, + argument is provided, and the `inherit_name` argument is not explicitly + set to `False`, the unconnected port of `wire` is automatically renamed to + 'myport'. This allows easy extension of existing ports without changing + their names or having to provide `map_out` each time `plug` is called. + + - `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})` + instantiates `pad` at the specified (x, y) offset and with the specified + rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is + renamed to 'gnd' so that further routing can use this signal or net name + rather than the port name on the original `pad` device. + """ + __slots__ = ('pattern', 'ports', 'tools', '_dead') + + pattern: Pattern + """ Layout of this device """ + + ports: Dict[str, Port] + """ Uniquely-named ports which can be used to snap to other Device instances""" + + tools: Dict[Optional[str], Tool] + """ + Tool objects are used to dynamically generate new single-use Devices + (e.g wires or waveguides) to be plugged into this device. + """ + + _dead: bool + """ If True, plug()/place() are skipped (for debugging)""" + + def __init__( + self, + pattern: Optional[Pattern] = None, + ports: Optional[Dict[str, Port]] = None, + *, + tools: Union[None, Tool, Dict[Optional[str], Tool]] = None, + name: Optional[str] = None, + ) -> None: + """ + If `ports` is `None`, two default ports ('A' and 'B') are created. + Both are placed at (0, 0) and have default `ptype`, but 'A' has rotation 0 + (attached devices will be placed to the left) and 'B' has rotation + pi (attached devices will be placed to the right). + """ + if pattern is not None: + if name is not None: + raise DeviceError('Only one of `pattern` and `name` may be specified') + self.pattern = pattern + else: + if name is None: + raise DeviceError('Must specify either `pattern` or `name`') + self.pattern = Pattern(name=name) + + if ports is None: + self.ports = { + 'A': Port([0, 0], rotation=0), + 'B': Port([0, 0], rotation=pi), + } + else: + self.ports = copy.deepcopy(ports) + + if tools is None: + self.tools = {} + elif isinstance(tools, Tool): + self.tools = {None: tools} + else: + self.tools = tools + + self._dead = False + + @overload + def __getitem__(self, key: str) -> Port: + pass + + @overload + def __getitem__(self, key: Union[List[str], Tuple[str, ...], KeysView[str], ValuesView[str]]) -> Dict[str, Port]: + pass + + def __getitem__(self, key: Union[str, Iterable[str]]) -> Union[Port, Dict[str, Port]]: + """ + For convenience, ports can be read out using square brackets: + - `device['A'] == Port((0, 0), 0)` + - `device[['A', 'B']] == {'A': Port((0, 0), 0), + 'B': Port((0, 0), pi)}` + """ + if isinstance(key, str): + return self.ports[key] + else: + return {k: self.ports[k] for k in key} + + def rename_ports( + self: D, + mapping: Dict[str, Optional[str]], + overwrite: bool = False, + ) -> D: + """ + Renames ports as specified by `mapping`. + Ports can be explicitly deleted by mapping them to `None`. + + Args: + mapping: Dict of `{'old_name': 'new_name'}` pairs. Names can be mapped + to `None` to perform an explicit deletion. `'new_name'` can also + overwrite an existing non-renamed port to implicitly delete it if + `overwrite` is set to `True`. + overwrite: Allows implicit deletion of ports if set to `True`; see `mapping`. + + Returns: + self + """ + if not overwrite: + duplicates = (set(self.ports.keys()) - set(mapping.keys())) & set(mapping.values()) + if duplicates: + raise DeviceError(f'Unrenamed ports would be overwritten: {duplicates}') + + renamed = {mapping[k]: self.ports.pop(k) for k in mapping.keys()} + if None in renamed: + del renamed[None] + + self.ports.update(renamed) # type: ignore + return self + + def check_ports( + self: D, + other_names: Iterable[str], + map_in: Optional[Dict[str, str]] = None, + map_out: Optional[Dict[str, Optional[str]]] = None, + ) -> D: + """ + Given the provided port mappings, check that: + - All of the ports specified in the mappings exist + - There are no duplicate port names after all the mappings are performed + + Args: + other_names: List of port names being considered for inclusion into + `self.ports` (before mapping) + map_in: Dict of `{'self_port': 'other_port'}` mappings, specifying + port connections between the two devices. + map_out: Dict of `{'old_name': 'new_name'}` mappings, specifying + new names for unconnected `other_names` ports. + + Returns: + self + + Raises: + `DeviceError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other_names`. + `DeviceError` if there are any duplicate names after `map_in` and `map_out` + are applied. + """ + if map_in is None: + map_in = {} + + if map_out is None: + map_out = {} + + other = set(other_names) + + missing_inkeys = set(map_in.keys()) - set(self.ports.keys()) + if missing_inkeys: + raise DeviceError(f'`map_in` keys not present in device: {missing_inkeys}') + + missing_invals = set(map_in.values()) - other + if missing_invals: + raise DeviceError(f'`map_in` values not present in other device: {missing_invals}') + + missing_outkeys = set(map_out.keys()) - other + if missing_outkeys: + raise DeviceError(f'`map_out` keys not present in other device: {missing_outkeys}') + + orig_remaining = set(self.ports.keys()) - set(map_in.keys()) + other_remaining = other - set(map_out.keys()) - set(map_in.values()) + mapped_vals = set(map_out.values()) + mapped_vals.discard(None) + + conflicts_final = orig_remaining & (other_remaining | mapped_vals) + if conflicts_final: + raise DeviceError(f'Device ports conflict with existing ports: {conflicts_final}') + + conflicts_partial = other_remaining & mapped_vals + if conflicts_partial: + raise DeviceError(f'`map_out` targets conflict with non-mapped outputs: {conflicts_partial}') + + map_out_counts = Counter(map_out.values()) + map_out_counts[None] = 0 + conflicts_out = {k for k, v in map_out_counts.items() if v > 1} + if conflicts_out: + raise DeviceError(f'Duplicate targets in `map_out`: {conflicts_out}') + + return self + + def build(self, name: str) -> 'Device': + """ + Begin building a new device around an instance of the current device + (rather than modifying the current device). + + Args: + name: A name for the new device + + Returns: + The new `Device` object. + """ + pat = Pattern(name) + pat.addsp(self.pattern) + new = Device(pat, ports=self.ports, tools=self.tools) + return new + + def as_interface( + self, + name: str, + in_prefix: str = 'in_', + out_prefix: str = '', + port_map: Optional[Union[Dict[str, str], Sequence[str]]] = None + ) -> 'Device': + """ + Begin building a new device based on all or some of the ports in the + current device. Do not include the current device; instead use it + to define ports (the "interface") for the new device. + + The ports specified by `port_map` (default: all ports) are copied to + new device, and additional (input) ports are created facing in the + opposite directions. The specified `in_prefix` and `out_prefix` are + prepended to the port names to differentiate them. + + By default, the flipped ports are given an 'in_' prefix and unflipped + ports keep their original names, enabling intuitive construction of + a device that will "plug into" the current device; the 'in_*' ports + are used for plugging the devices together while the original port + names are used for building the new device. + + Another use-case could be to build the new device using the 'in_' + ports, creating a new device which could be used in place of the + current device. + + Args: + name: Name for the new device + in_prefix: Prepended to port names for newly-created ports with + reversed directions compared to the current device. + out_prefix: Prepended to port names for ports which are directly + copied from the current device. + port_map: Specification for ports to copy into the new device: + - If `None`, all ports are copied. + - If a sequence, only the listed ports are copied + - If a mapping, the listed ports (keys) are copied and + renamed (to the values). + + Returns: + The new device, with an empty pattern and 2x as many ports as + listed in port_map. + + Raises: + `DeviceError` if `port_map` contains port names not present in the + current device. + `DeviceError` if applying the prefixes results in duplicate port + names. + """ + if port_map: + if isinstance(port_map, dict): + missing_inkeys = set(port_map.keys()) - set(self.ports.keys()) + orig_ports = {port_map[k]: v for k, v in self.ports.items() if k in port_map} + else: + port_set = set(port_map) + missing_inkeys = port_set - set(self.ports.keys()) + orig_ports = {k: v for k, v in self.ports.items() if k in port_set} + + if missing_inkeys: + raise DeviceError(f'`port_map` keys not present in device: {missing_inkeys}') + else: + orig_ports = self.ports + + ports_in = {f'{in_prefix}{name}': port.deepcopy().rotate(pi) + for name, port in orig_ports.items()} + ports_out = {f'{out_prefix}{name}': port.deepcopy() + for name, port in orig_ports.items()} + + duplicates = set(ports_out.keys()) & set(ports_in.keys()) + if duplicates: + raise DeviceError(f'Duplicate keys after prefixing, try a different prefix: {duplicates}') + + new = Device(name=name, ports={**ports_in, **ports_out}, tools=self.tools) + return new + + def plug( + self: D, + other: O, + map_in: Dict[str, str], + map_out: Optional[Dict[str, Optional[str]]] = None, + *, + mirrored: Tuple[bool, bool] = (False, False), + inherit_name: bool = True, + set_rotation: Optional[bool] = None, + ) -> D: + """ + Instantiate the device `other` into the current device, connecting + the ports specified by `map_in` and renaming the unconnected + ports specified by `map_out`. + + Examples: + ========= + - `my_device.plug(subdevice, {'A': 'C', 'B': 'B'}, map_out={'D': 'myport'})` + instantiates `subdevice` into `my_device`, plugging ports 'A' and 'B' + of `my_device` into ports 'C' and 'B' of `subdevice`. The connected ports + are removed and any unconnected ports from `subdevice` are added to + `my_device`. Port 'D' of `subdevice` (unconnected) is renamed to 'myport'. + + - `my_device.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' + of `my_device`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, + argument is provided, and the `inherit_name` argument is not explicitly + set to `False`, the unconnected port of `wire` is automatically renamed to + 'myport'. This allows easy extension of existing ports without changing + their names or having to provide `map_out` each time `plug` is called. + + Args: + other: A device to instantiate into the current device. + map_in: Dict of `{'self_port': 'other_port'}` mappings, specifying + port connections between the two devices. + map_out: Dict of `{'old_name': 'new_name'}` mappings, specifying + new names for ports in `other`. + mirrored: Enables mirroring `other` across the x or y axes prior + to connecting any ports. + inherit_name: If `True`, and `map_in` specifies only a single port, + and `map_out` is `None`, and `other` has only two ports total, + then automatically renames the output port of `other` to the + name of the port from `self` that appears in `map_in`. This + makes it easy to extend a device with simple 2-port devices + (e.g. wires) without providing `map_out` each time `plug` is + called. See "Examples" above for more info. Default `True`. + set_rotation: If the necessary rotation cannot be determined from + the ports being connected (i.e. all pairs have at least one + port with `rotation=None`), `set_rotation` must be provided + to indicate how much `other` should be rotated. Otherwise, + `set_rotation` must remain `None`. + + Returns: + self + + Raises: + `DeviceError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other_names`. + `DeviceError` if there are any duplicate names after `map_in` and `map_out` + are applied. + `DeviceError` if the specified port mapping is not achieveable (the ports + do not line up) + """ + if self._dead: + logger.error('Skipping plug() since device is dead') + return self + + if (inherit_name + and not map_out + and len(map_in) == 1 + and len(other.ports) == 2): + out_port_name = next(iter(set(other.ports.keys()) - set(map_in.values()))) + map_out = {out_port_name: next(iter(map_in.keys()))} + + if map_out is None: + map_out = {} + map_out = copy.deepcopy(map_out) + + self.check_ports(other.ports.keys(), map_in, map_out) + translation, rotation, pivot = self.find_transform(other, map_in, mirrored=mirrored, + set_rotation=set_rotation) + + # get rid of plugged ports + for ki, vi in map_in.items(): + del self.ports[ki] + map_out[vi] = None + + self.place(other, offset=translation, rotation=rotation, pivot=pivot, + mirrored=mirrored, port_map=map_out, skip_port_check=True) + return self + + def place( + self: D, + other: O, + *, + offset: ArrayLike = (0, 0), + rotation: float = 0, + pivot: ArrayLike = (0, 0), + mirrored: Tuple[bool, bool] = (False, False), + port_map: Optional[Dict[str, Optional[str]]] = None, + skip_port_check: bool = False, + ) -> D: + """ + Instantiate the device `other` into the current device, adding its + ports to those of the current device (but not connecting any ports). + + Mirroring is applied before rotation; translation (`offset`) is applied last. + + Examples: + ========= + - `my_device.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})` + instantiates `pad` at the specified (x, y) offset and with the specified + rotation, adding its ports to those of `my_device`. Port 'A' of `pad` is + renamed to 'gnd' so that further routing can use this signal or net name + rather than the port name on the original `pad` device. + + Args: + other: A device to instantiate into the current device. + offset: Offset at which to place `other`. Default (0, 0). + rotation: Rotation applied to `other` before placement. Default 0. + pivot: Rotation is applied around this pivot point (default (0, 0)). + Rotation is applied prior to translation (`offset`). + mirrored: Whether `other` should be mirrored across the x and y axes. + Mirroring is applied before translation and rotation. + port_map: Dict of `{'old_name': 'new_name'}` mappings, specifying + new names for ports in `other`. New names can be `None`, which will + delete those ports. + skip_port_check: Can be used to skip the internal call to `check_ports`, + in case it has already been performed elsewhere. + + Returns: + self + + Raises: + `DeviceError` if any ports specified in `map_in` or `map_out` do not + exist in `self.ports` or `other_names`. + `DeviceError` if there are any duplicate names after `map_in` and `map_out` + are applied. + """ + if self._dead: + logger.error('Skipping place() since device is dead') + return self + + if port_map is None: + port_map = {} + + if not skip_port_check: + self.check_ports(other.ports.keys(), map_in=None, map_out=port_map) + + ports = {} + for name, port in other.ports.items(): + new_name = port_map.get(name, name) + if new_name is None: + continue + ports[new_name] = port + + for name, port in ports.items(): + p = port.deepcopy() + p.mirror2d(mirrored) + p.rotate_around(pivot, rotation) + p.translate(offset) + self.ports[name] = p + + sp = SubPattern(other.pattern, mirrored=mirrored) + sp.rotate_around(pivot, rotation) + sp.translate(offset) + self.pattern.subpatterns.append(sp) + return self + + def find_transform( + self: D, + other: O, + map_in: Dict[str, str], + *, + mirrored: Tuple[bool, bool] = (False, False), + set_rotation: Optional[bool] = None, + ) -> Tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]: + """ + Given a device `other` and a mapping `map_in` specifying port connections, + find the transform which will correctly align the specified ports. + + Args: + other: a device + map_in: Dict of `{'self_port': 'other_port'}` mappings, specifying + port connections between the two devices. + mirrored: Mirrors `other` across the x or y axes prior to + connecting any ports. + set_rotation: If the necessary rotation cannot be determined from + the ports being connected (i.e. all pairs have at least one + port with `rotation=None`), `set_rotation` must be provided + to indicate how much `other` should be rotated. Otherwise, + `set_rotation` must remain `None`. + + Returns: + - The (x, y) translation (performed last) + - The rotation (radians, counterclockwise) + - The (x, y) pivot point for the rotation + + The rotation should be performed before the translation. + """ + s_ports = self[map_in.keys()] + o_ports = other[map_in.values()] + + s_offsets = numpy.array([p.offset for p in s_ports.values()]) + o_offsets = numpy.array([p.offset for p in o_ports.values()]) + s_types = [p.ptype for p in s_ports.values()] + o_types = [p.ptype for p in o_ports.values()] + + s_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in s_ports.values()]) + o_rotations = numpy.array([p.rotation if p.rotation is not None else 0 for p in o_ports.values()]) + s_has_rot = numpy.array([p.rotation is not None for p in s_ports.values()], dtype=bool) + o_has_rot = numpy.array([p.rotation is not None for p in o_ports.values()], dtype=bool) + has_rot = s_has_rot & o_has_rot + + if mirrored[0]: + o_offsets[:, 1] *= -1 + o_rotations *= -1 + if mirrored[1]: + o_offsets[:, 0] *= -1 + o_rotations *= -1 + o_rotations += pi + + type_conflicts = numpy.array([st != ot and st != 'unk' and ot != 'unk' + for st, ot in zip(s_types, o_types)]) + if type_conflicts.any(): + ports = numpy.where(type_conflicts) + msg = 'Ports have conflicting types:\n' + for nn, (k, v) in enumerate(map_in.items()): + if type_conflicts[nn]: + msg += f'{k} | {s_types[nn]}:{o_types[nn]} | {v}\n' + msg = ''.join(traceback.format_stack()) + '\n' + msg + warnings.warn(msg, stacklevel=2) + + rotations = numpy.mod(s_rotations - o_rotations - pi, 2 * pi) + if not has_rot.any(): + if set_rotation is None: + DeviceError('Must provide set_rotation if rotation is indeterminate') + rotations[:] = set_rotation + else: + rotations[~has_rot] = rotations[has_rot][0] + + if not numpy.allclose(rotations[:1], rotations): + rot_deg = numpy.rad2deg(rotations) + msg = f'Port orientations do not match:\n' + for nn, (k, v) in enumerate(map_in.items()): + msg += f'{k} | {rot_deg[nn]:g} | {v}\n' + raise DeviceError(msg) + + pivot = o_offsets[0].copy() + rotate_offsets_around(o_offsets, pivot, rotations[0]) + translations = s_offsets - o_offsets + if not numpy.allclose(translations[:1], translations): + msg = f'Port translations do not match:\n' + for nn, (k, v) in enumerate(map_in.items()): + msg += f'{k} | {translations[nn]} | {v}\n' + raise DeviceError(msg) + + return translations[0], rotations[0], o_offsets[0] + + def translate(self: D, offset: ArrayLike) -> D: + """ + Translate the pattern and all ports. + + Args: + offset: (x, y) distance to translate by + + Returns: + self + """ + self.pattern.translate_elements(offset) + for port in self.ports.values(): + port.translate(offset) + return self + + def rotate_around(self: D, pivot: ArrayLike, angle: float) -> D: + """ + Translate the pattern and all ports. + + Args: + offset: (x, y) distance to translate by + + Returns: + self + """ + self.pattern.rotate_around(pivot, angle) + for port in self.ports.values(): + port.rotate_around(pivot, angle) + return self + + def mirror(self: D, axis: int) -> D: + """ + Translate the pattern and all ports across the specified axis. + + Args: + axis: Axis to mirror across (x=0, y=1) + + Returns: + self + """ + self.pattern.mirror(axis) + for p in self.ports.values(): + p.mirror(axis) + return self + + def set_dead(self: D) -> D: + """ + Disallows further changes through `plug()` or `place()`. + This is meant for debugging: + ``` + dev.plug(a, ...) + dev.set_dead() # added for debug purposes + dev.plug(b, ...) # usually raises an error, but now skipped + dev.plug(c, ...) # also skipped + dev.pattern.visualize() # shows the device as of the set_dead() call + ``` + + Returns: + self + """ + self._dead = True + return self + + def rename(self: D, name: str) -> D: + """ + Renames the pattern and returns the device + + Args: + name: The new name + + Returns: + self + """ + self.pattern.name = name + return self + + def __repr__(self) -> str: + s = f' D: + if keys is None or isinstance(keys, str): + self.tools[keys] = tool + else: + for key in keys: + self.tools[key] = tool + return self + + def path( + self: D, + portspec: str, + ccw: Optional[bool], + length: float, + *, + tool_port_names: Sequence[str] = ('A', 'B'), + **kwargs, + ) -> D: + if self._dead: + logger.error('Skipping path() since device is dead') + return self + + tool = self.tools.get(portspec, self.tools[None]) + in_ptype = self.ports[portspec].ptype + dev = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) + return self.plug(dev, {portspec: tool_port_names[0]}) + + def path_to( + self: D, + portspec: str, + ccw: Optional[bool], + position: float, + *, + tool_port_names: Sequence[str] = ('A', 'B'), + **kwargs, + ) -> D: + if self._dead: + logger.error('Skipping path_to() since device is dead') + return self + + port = self.ports[portspec] + x, y = port.offset + if port.rotation is None: + raise DeviceError(f'Port {portspec} has no rotation and cannot be used for path_to()') + + if not numpy.isclose(port.rotation % (pi / 2), 0): + raise DeviceError('path_to was asked to route from non-manhattan port') + + is_horizontal = numpy.isclose(port.rotation % pi, 0) + if is_horizontal: + if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x): + raise DeviceError(f'path_to routing to behind source port: x={x:g} to {position:g}') + length = numpy.abs(position - x) + else: + if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y): + raise DeviceError(f'path_to routing to behind source port: y={y:g} to {position:g}') + length = numpy.abs(position - y) + + return self.path(portspec, ccw, length, tool_port_names=tool_port_names, **kwargs) + + def busL( + self: D, + portspec: Union[str, Sequence[str]], + ccw: Optional[bool], + *, + spacing: Optional[Union[float, ArrayLike]] = None, + set_rotation: Optional[float] = None, + tool_port_names: Sequence[str] = ('A', 'B'), + container_name: str = '_busL', + force_container: bool = False, + **kwargs, + ) -> D: + if self._dead: + logger.error('Skipping busL() since device is dead') + return self + + bound_types = set() + if 'bound_type' in kwargs: + bound_types.add(kwargs['bound_type']) + bound = kwargs['bound'] + for bt in ('emin', 'emax', 'pmin', 'pmax', 'min_past_furthest'): + if bt in kwargs: + bound_types.add(bt) + bound = kwargs[bt] + + if not bound_types: + raise DeviceError('No bound type specified for busL') + elif len(bound_types) > 1: + raise DeviceError(f'Too many bound types specified for busL: {bound_types}') + bound_type = tuple(bound_types)[0] + + if isinstance(portspec, str): + portspec = [portspec] + ports = self[tuple(portspec)] + + extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) + + if len(ports) == 1 and not force_container: + # Not a bus, so having a container just adds noise to the layout + port_name = tuple(portspec)[0] + return self.path(port_name, ccw, extensions[port_name], tool_port_names=tool_port_names) + else: + dev = Device(name='', ports=ports, tools=self.tools).as_interface(container_name) + for name, length in extensions.items(): + dev.path(name, ccw, length, tool_port_names=tool_port_names) + return self.plug(dev, {sp: 'in_' + sp for sp in ports.keys()}) # TODO safe to use 'in_'? + + # TODO def path_join() and def bus_join()? + + +def rotate_offsets_around( + offsets: NDArray[numpy.float64], + pivot: NDArray[numpy.float64], + angle: float, + ) -> NDArray[numpy.float64]: + offsets -= pivot + offsets[:] = (rotation_matrix_2d(angle) @ offsets.T).T + offsets += pivot + return offsets diff --git a/masque/builder/pather.py b/masque/builder/pather.py deleted file mode 100644 index 9af473d..0000000 --- a/masque/builder/pather.py +++ /dev/null @@ -1,375 +0,0 @@ -""" -Manual wire/waveguide routing (`Pather`) -""" -from typing import Self -from collections.abc import Sequence, Mapping, MutableMapping -import copy -import logging -from pprint import pformat - -from ..pattern import Pattern -from ..library import ILibrary -from ..error import BuildError -from ..ports import PortList, Port -from ..utils import SupportsBool -from .tools import Tool -from .pather_mixin import PatherMixin -from .builder import Builder - - -logger = logging.getLogger(__name__) - - -class Pather(Builder, PatherMixin): - """ - An extension of `Builder` which provides functionality for routing and attaching - single-use patterns (e.g. wires or waveguides) and bundles / buses of such patterns. - - `Pather` is mostly concerned with calculating how long each wire should be. It calls - out to `Tool.path` functions provided by subclasses of `Tool` to build the actual patterns. - `Tool`s are assigned on a per-port basis and stored in `.tools`; a key of `None` represents - a "default" `Tool` used for all ports which do not have a port-specific `Tool` assigned. - - - Examples: Creating a Pather - =========================== - - `Pather(library, tools=my_tool)` makes an empty pattern with no ports. The pattern - is not added into `library` and must later be added with e.g. - `library['mypat'] = pather.pattern`. - The default wire/waveguide generating tool for all ports is set to `my_tool`. - - - `Pather(library, ports={'in': Port(...), 'out': ...}, name='mypat', tools=my_tool)` - makes an empty pattern, adds the given ports, and places it into `library` - under the name `'mypat'`. The default wire/waveguide generating tool - for all ports is set to `my_tool` - - - `Pather(..., tools={'in': top_metal_40um, 'out': bottom_metal_1um, None: my_tool})` - assigns specific tools to individual ports, and `my_tool` as a default for ports - which are not specified. - - - `Pather.interface(other_pat, port_map=['A', 'B'], library=library, tools=my_tool)` - makes a new (empty) pattern, copies over ports 'A' and 'B' from - `other_pat`, and creates additional ports 'in_A' and 'in_B' facing - in the opposite directions. This can be used to build a device which - can plug into `other_pat` (using the 'in_*' ports) but which does not - itself include `other_pat` as a subcomponent. - - - `Pather.interface(other_pather, ...)` does the same thing as - `Builder.interface(other_builder.pattern, ...)` but also uses - `other_builder.library` as its library by default. - - - Examples: Adding to a pattern - ============================= - - `pather.path('my_port', ccw=True, distance)` creates a "wire" for which the output - port is `distance` units away along the axis of `'my_port'` and rotated 90 degrees - counterclockwise (since `ccw=True`) relative to `'my_port'`. The wire is `plug`ged - into the existing `'my_port'`, causing the port to move to the wire's output. - - There is no formal guarantee about how far off-axis the output will be located; - there may be a significant width to the bend that is used to accomplish the 90 degree - turn. However, an error is raised if `distance` is too small to fit the bend. - - - `pather.path('my_port', ccw=None, distance)` creates a straight wire with a length - of `distance` and `plug`s it into `'my_port'`. - - - `pather.path_to('my_port', ccw=False, position)` creates a wire which starts at - `'my_port'` and has its output at the specified `position`, pointing 90 degrees - clockwise relative to the input. Again, the off-axis position or distance to the - output is not specified, so `position` takes the form of a single coordinate. To - ease debugging, position may be specified as `x=position` or `y=position` and an - error will be raised if the wrong coordinate is given. - - - `pather.mpath(['A', 'B', 'C'], ..., spacing=spacing)` is a superset of `path` - and `path_to` which can act on multiple ports simultaneously. Each port's wire is - generated using its own `Tool` (or the default tool if left unspecified). - The output ports are spaced out by `spacing` along the input ports' axis, unless - `ccw=None` is specified (i.e. no bends) in which case they all end at the same - destination coordinate. - - - `pather.plug(wire, {'myport': 'A'})` places port 'A' of `wire` at 'myport' - of `pather.pattern`. If `wire` has only two ports (e.g. 'A' and 'B'), no `map_out`, - argument is provided, and the `inherit_name` argument is not explicitly - set to `False`, the unconnected port of `wire` is automatically renamed to - 'myport'. This allows easy extension of existing ports without changing - their names or having to provide `map_out` each time `plug` is called. - - - `pather.place(pad, offset=(10, 10), rotation=pi / 2, port_map={'A': 'gnd'})` - instantiates `pad` at the specified (x, y) offset and with the specified - rotation, adding its ports to those of `pather.pattern`. Port 'A' of `pad` is - renamed to 'gnd' so that further routing can use this signal or net name - rather than the port name on the original `pad` device. - - - `pather.retool(tool)` or `pather.retool(tool, ['in', 'out', None])` can change - which tool is used for the given ports (or as the default tool). Useful - when placing vias or using multiple waveguide types along a route. - """ - __slots__ = ('tools',) - - library: ILibrary - """ - Library from which existing patterns should be referenced, and to which - new ones should be added - """ - - tools: dict[str | None, Tool] - """ - Tool objects are used to dynamically generate new single-use `Pattern`s - (e.g wires or waveguides) to be plugged into this device. A key of `None` - indicates the default `Tool`. - """ - - def __init__( - self, - library: ILibrary, - *, - pattern: Pattern | None = None, - ports: str | Mapping[str, Port] | None = None, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - name: str | None = None, - ) -> None: - """ - Args: - library: The library from which referenced patterns will be taken, - and where new patterns (e.g. generated by the `tools`) will be placed. - pattern: The pattern which will be modified by subsequent operations. - If `None` (default), a new pattern is created. - ports: Allows specifying the initial set of ports, if `pattern` does - not already have any ports (or is not provided). May be a string, - in which case it is interpreted as a name in `library`. - Default `None` (no ports). - tools: A mapping of {port: tool} which specifies what `Tool` should be used - to generate waveguide or wire segments when `path`/`path_to`/`mpath` - are called. Relies on `Tool.path` implementations. - name: If specified, `library[name]` is set to `self.pattern`. - """ - self._dead = False - self.library = library - if pattern is not None: - self.pattern = pattern - else: - self.pattern = Pattern() - - if ports is not None: - if self.pattern.ports: - raise BuildError('Ports supplied for pattern with pre-existing ports!') - if isinstance(ports, str): - ports = library.abstract(ports).ports - - self.pattern.ports.update(copy.deepcopy(dict(ports))) - - if name is not None: - library[name] = self.pattern - - if tools is None: - self.tools = {} - elif isinstance(tools, Tool): - self.tools = {None: tools} - else: - self.tools = dict(tools) - - @classmethod - def from_builder( - cls: type['Pather'], - builder: Builder, - *, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - ) -> 'Pather': - """ - Construct a `Pather` by adding tools to a `Builder`. - - Args: - builder: Builder to turn into a Pather - tools: Tools for the `Pather` - - Returns: - A new Pather object, using `builder.library` and `builder.pattern`. - """ - new = Pather(library=builder.library, tools=tools, pattern=builder.pattern) - return new - - @classmethod - def interface( - cls: type['Pather'], - source: PortList | Mapping[str, Port] | str, - *, - library: ILibrary | None = None, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - in_prefix: str = 'in_', - out_prefix: str = '', - port_map: dict[str, str] | Sequence[str] | None = None, - name: str | None = None, - ) -> 'Pather': - """ - Wrapper for `Pattern.interface()`, which returns a Pather instead. - - Args: - source: A collection of ports (e.g. Pattern, Builder, or dict) - from which to create the interface. May be a pattern name if - `library` is provided. - library: Library from which existing patterns should be referenced, - and to which the new one should be added (if named). If not provided, - `source.library` must exist and will be used. - tools: `Tool`s which will be used by the pather for generating new wires - or waveguides (via `path`/`path_to`/`mpath`). - in_prefix: Prepended to port names for newly-created ports with - reversed directions compared to the current device. - out_prefix: Prepended to port names for ports which are directly - copied from the current device. - port_map: Specification for ports to copy into the new device: - - If `None`, all ports are copied. - - If a sequence, only the listed ports are copied - - If a mapping, the listed ports (keys) are copied and - renamed (to the values). - - Returns: - The new pather, with an empty pattern and 2x as many ports as - listed in port_map. - - Raises: - `PortError` if `port_map` contains port names not present in the - current device. - `PortError` if applying the prefixes results in duplicate port - names. - """ - if library is None: - if hasattr(source, 'library') and isinstance(source.library, ILibrary): - library = source.library - else: - raise BuildError('No library provided (and not present in `source.library`') - - if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict): - tools = source.tools - - if isinstance(source, str): - source = library.abstract(source).ports - - pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map) - new = Pather(library=library, pattern=pat, name=name, tools=tools) - return new - - def __repr__(self) -> str: - s = f'' - return s - - - def path( - self, - portspec: str, - ccw: SupportsBool | None, - length: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Create a "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim - of traveling exactly `length` distance. - - The wire will travel `length` distance along the port's axis, and an unspecified - (tool-dependent) distance in the perpendicular direction. The output port will - be rotated (or not) based on the `ccw` parameter. - - Args: - portspec: The name of the port into which the wire will be plugged. - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - length: The total distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - Raises: - BuildError if `distance` is too small to fit the bend (if a bend is present). - LibraryError if no valid name could be picked for the pattern. - """ - if self._dead: - logger.error('Skipping path() since device is dead') - return self - - tool_port_names = ('A', 'B') - - tool = self.tools.get(portspec, self.tools[None]) - in_ptype = self.pattern[portspec].ptype - tree = tool.path(ccw, length, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) - tname = self.library << tree - if plug_into is not None: - output = {plug_into: tool_port_names[1]} - else: - output = {} - self.plug(tname, {portspec: tool_port_names[0], **output}) - return self - - def pathS( - self, - portspec: str, - length: float, - jog: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim - of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is - left of direction of travel). - - The output port will have the same orientation as the source port (`portspec`). - - This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former - raises a NotImplementedError. - - Args: - portspec: The name of the port into which the wire will be plugged. - jog: Total manhattan distance perpendicular to the direction of travel. - Positive values are to the left of the direction of travel. - length: The total manhattan distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - Raises: - BuildError if `distance` is too small to fit the s-bend (for nonzero jog). - LibraryError if no valid name could be picked for the pattern. - """ - if self._dead: - logger.error('Skipping pathS() since device is dead') - return self - - tool_port_names = ('A', 'B') - - tool = self.tools.get(portspec, self.tools[None]) - in_ptype = self.pattern[portspec].ptype - try: - tree = tool.pathS(length, jog, in_ptype=in_ptype, port_names=tool_port_names, **kwargs) - except NotImplementedError: - # Fall back to drawing two L-bends - ccw0 = jog > 0 - kwargs_no_out = kwargs | {'out_ptype': None} - t_tree0 = tool.path( ccw0, length / 2, port_names=tool_port_names, in_ptype=in_ptype, **kwargs_no_out) - t_pat0 = t_tree0.top_pattern() - (_, jog0), _ = t_pat0[tool_port_names[0]].measure_travel(t_pat0[tool_port_names[1]]) - t_tree1 = tool.path(not ccw0, abs(jog - jog0), port_names=tool_port_names, in_ptype=t_pat0[tool_port_names[1]].ptype, **kwargs) - t_pat1 = t_tree1.top_pattern() - (_, jog1), _ = t_pat1[tool_port_names[0]].measure_travel(t_pat1[tool_port_names[1]]) - - kwargs_plug = kwargs | {'plug_into': plug_into} - self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) - self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) - return self - - tname = self.library << tree - if plug_into is not None: - output = {plug_into: tool_port_names[1]} - else: - output = {} - self.plug(tname, {portspec: tool_port_names[0], **output}) - return self - diff --git a/masque/builder/pather_mixin.py b/masque/builder/pather_mixin.py deleted file mode 100644 index 1655329..0000000 --- a/masque/builder/pather_mixin.py +++ /dev/null @@ -1,677 +0,0 @@ -from typing import Self, overload -from collections.abc import Sequence, Iterator, Iterable -import logging -from contextlib import contextmanager -from abc import abstractmethod, ABCMeta - -import numpy -from numpy import pi -from numpy.typing import ArrayLike - -from ..pattern import Pattern -from ..library import ILibrary, TreeView -from ..error import PortError, BuildError -from ..utils import SupportsBool -from ..abstract import Abstract -from .tools import Tool -from .utils import ell -from ..ports import PortList - - -logger = logging.getLogger(__name__) - - -class PatherMixin(PortList, metaclass=ABCMeta): - pattern: Pattern - """ Layout of this device """ - - library: ILibrary - """ Library from which patterns should be referenced """ - - _dead: bool - """ If True, plug()/place() are skipped (for debugging) """ - - tools: dict[str | None, Tool] - """ - Tool objects are used to dynamically generate new single-use Devices - (e.g wires or waveguides) to be plugged into this device. - """ - - @abstractmethod - def path( - self, - portspec: str, - ccw: SupportsBool | None, - length: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - pass - - @abstractmethod - def pathS( - self, - portspec: str, - length: float, - jog: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - pass - - @abstractmethod - def plug( - self, - other: Abstract | str | Pattern | TreeView, - map_in: dict[str, str], - map_out: dict[str, str | None] | None = None, - *, - mirrored: bool = False, - thru: bool | str = True, - set_rotation: bool | None = None, - append: bool = False, - ok_connections: Iterable[tuple[str, str]] = (), - ) -> Self: - pass - - def retool( - self, - tool: Tool, - keys: str | Sequence[str | None] | None = None, - ) -> Self: - """ - Update the `Tool` which will be used when generating `Pattern`s for the ports - given by `keys`. - - Args: - tool: The new `Tool` to use for the given ports. - keys: Which ports the tool should apply to. `None` indicates the default tool, - used when there is no matching entry in `self.tools` for the port in question. - - Returns: - self - """ - if keys is None or isinstance(keys, str): - self.tools[keys] = tool - else: - for key in keys: - self.tools[key] = tool - return self - - @contextmanager - def toolctx( - self, - tool: Tool, - keys: str | Sequence[str | None] | None = None, - ) -> Iterator[Self]: - """ - Context manager for temporarily `retool`-ing and reverting the `retool` - upon exiting the context. - - Args: - tool: The new `Tool` to use for the given ports. - keys: Which ports the tool should apply to. `None` indicates the default tool, - used when there is no matching entry in `self.tools` for the port in question. - - Returns: - self - """ - if keys is None or isinstance(keys, str): - keys = [keys] - saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None` - try: - yield self.retool(tool=tool, keys=keys) - finally: - for kk, tt in saved_tools.items(): - if tt is None: - # delete if present - self.tools.pop(kk, None) - else: - self.tools[kk] = tt - - def path_to( - self, - portspec: str, - ccw: SupportsBool | None, - position: float | None = None, - *, - x: float | None = None, - y: float | None = None, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Build a "wire"/"waveguide" extending from the port `portspec`, with the aim - of ending exactly at a target position. - - The wire will travel so that the output port will be placed at exactly the target - position along the input port's axis. There can be an unspecified (tool-dependent) - offset in the perpendicular direction. The output port will be rotated (or not) - based on the `ccw` parameter. - - If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec: The name of the port into which the wire will be plugged. - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - position: The final port position, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - Only one of `position`, `x`, and `y` may be specified. - x: The final port position along the x axis. - `portspec` must refer to a horizontal port if `x` is passed, otherwise a - BuildError will be raised. - y: The final port position along the y axis. - `portspec` must refer to a vertical port if `y` is passed, otherwise a - BuildError will be raised. - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - Raises: - BuildError if `position`, `x`, or `y` is too close to fit the bend (if a bend - is present). - BuildError if `x` or `y` is specified but does not match the axis of `portspec`. - BuildError if more than one of `x`, `y`, and `position` is specified. - """ - if self._dead: - logger.error('Skipping path_to() since device is dead') - return self - - pos_count = sum(vv is not None for vv in (position, x, y)) - if pos_count > 1: - raise BuildError('Only one of `position`, `x`, and `y` may be specified at once') - if pos_count < 1: - raise BuildError('One of `position`, `x`, and `y` must be specified') - - port = self.pattern[portspec] - if port.rotation is None: - raise PortError(f'Port {portspec} has no rotation and cannot be used for path_to()') - - if not numpy.isclose(port.rotation % (pi / 2), 0): - raise BuildError('path_to was asked to route from non-manhattan port') - - is_horizontal = numpy.isclose(port.rotation % pi, 0) - if is_horizontal: - if y is not None: - raise BuildError('Asked to path to y-coordinate, but port is horizontal') - if position is None: - position = x - else: - if x is not None: - raise BuildError('Asked to path to x-coordinate, but port is vertical') - if position is None: - position = y - - x0, y0 = port.offset - if is_horizontal: - if numpy.sign(numpy.cos(port.rotation)) == numpy.sign(position - x0): - raise BuildError(f'path_to routing to behind source port: x0={x0:g} to {position:g}') - length = numpy.abs(position - x0) - else: - if numpy.sign(numpy.sin(port.rotation)) == numpy.sign(position - y0): - raise BuildError(f'path_to routing to behind source port: y0={y0:g} to {position:g}') - length = numpy.abs(position - y0) - - return self.path( - portspec, - ccw, - length, - plug_into = plug_into, - **kwargs, - ) - - def path_into( - self, - portspec_src: str, - portspec_dst: str, - *, - out_ptype: str | None = None, - plug_destination: bool = True, - thru: str | None = None, - **kwargs, - ) -> Self: - """ - Create a "wire"/"waveguide" traveling between the ports `portspec_src` and - `portspec_dst`, and `plug` it into both (or just the source port). - - Only unambiguous scenarios are allowed: - - Straight connector between facing ports - - Single 90 degree bend - - Jog between facing ports - (jog is done as late as possible, i.e. only 2 L-shaped segments are used) - - By default, the destination's `pytpe` will be used as the `out_ptype` for the - wire, and the `portspec_dst` will be plugged (i.e. removed). - - If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec_src: The name of the starting port into which the wire will be plugged. - portspec_dst: The name of the destination port. - out_ptype: Passed to the pathing tool in order to specify the desired port type - to be generated at the destination end. If `None` (default), the destination - port's `ptype` will be used. - thru: If not `None`, the port by this name will be rename to `portspec_src`. - This can be used when routing a signal through a pre-placed 2-port device. - - Returns: - self - - Raises: - PortError if either port does not have a specified rotation. - BuildError if and invalid port config is encountered: - - Non-manhattan ports - - U-bend - - Destination too close to (or behind) source - """ - if self._dead: - logger.error('Skipping path_into() since device is dead') - return self - - port_src = self.pattern[portspec_src] - port_dst = self.pattern[portspec_dst] - - if out_ptype is None: - out_ptype = port_dst.ptype - - if port_src.rotation is None: - raise PortError(f'Port {portspec_src} has no rotation and cannot be used for path_into()') - if port_dst.rotation is None: - raise PortError(f'Port {portspec_dst} has no rotation and cannot be used for path_into()') - - if not numpy.isclose(port_src.rotation % (pi / 2), 0): - raise BuildError('path_into was asked to route from non-manhattan port') - if not numpy.isclose(port_dst.rotation % (pi / 2), 0): - raise BuildError('path_into was asked to route to non-manhattan port') - - src_is_horizontal = numpy.isclose(port_src.rotation % pi, 0) - dst_is_horizontal = numpy.isclose(port_dst.rotation % pi, 0) - xs, ys = port_src.offset - xd, yd = port_dst.offset - - angle = (port_dst.rotation - port_src.rotation) % (2 * pi) - - dst_extra_args = {'out_ptype': out_ptype} - if plug_destination: - dst_extra_args['plug_into'] = portspec_dst - - src_args = {**kwargs} - dst_args = {**src_args, **dst_extra_args} - if src_is_horizontal and not dst_is_horizontal: - # single bend should suffice - self.path_to(portspec_src, angle > pi, x=xd, **src_args) - self.path_to(portspec_src, None, y=yd, **dst_args) - elif dst_is_horizontal and not src_is_horizontal: - # single bend should suffice - self.path_to(portspec_src, angle > pi, y=yd, **src_args) - self.path_to(portspec_src, None, x=xd, **dst_args) - elif numpy.isclose(angle, pi): - if src_is_horizontal and ys == yd: - # straight connector - self.path_to(portspec_src, None, x=xd, **dst_args) - elif not src_is_horizontal and xs == xd: - # straight connector - self.path_to(portspec_src, None, y=yd, **dst_args) - else: - # S-bend, delegate to implementations - (travel, jog), _ = port_src.measure_travel(port_dst) - self.pathS(portspec_src, -travel, -jog, **dst_args) - elif numpy.isclose(angle, 0): - raise BuildError('Don\'t know how to route a U-bend yet (TODO)!') - else: - raise BuildError(f'Don\'t know how to route ports with relative angle {angle}') - - if thru is not None: - self.rename_ports({thru: portspec_src}) - - return self - - def mpath( - self, - portspec: str | Sequence[str], - ccw: SupportsBool | None, - *, - spacing: float | ArrayLike | None = None, - set_rotation: float | None = None, - **kwargs, - ) -> Self: - """ - `mpath` is a superset of `path` and `path_to` which can act on bundles or buses - of "wires or "waveguides". - - The wires will travel so that the output ports will be placed at well-defined - locations along the axis of their input ports, but may have arbitrary (tool- - dependent) offsets in the perpendicular direction. - - If `ccw` is not `None`, the wire bundle will turn 90 degres in either the - clockwise (`ccw=False`) or counter-clockwise (`ccw=True`) direction. Within the - bundle, the center-to-center wire spacings after the turn are set by `spacing`, - which is required when `ccw` is not `None`. The final position of bundle as a - whole can be set in a number of ways: - - =A>---------------------------V turn direction: `ccw=False` - =B>-------------V | - =C>-----------------------V | - =D=>----------------V | - | - - x---x---x---x `spacing` (can be scalar or array) - - <--------------> `emin=` - <------> `bound_type='min_past_furthest', bound=` - <--------------------------------> `emax=` - x `pmin=` - x `pmax=` - - - `emin=`, equivalent to `bound_type='min_extension', bound=` - The total extension value for the furthest-out port (B in the diagram). - - `emax=`, equivalent to `bound_type='max_extension', bound=`: - The total extension value for the closest-in port (C in the diagram). - - `pmin=`, equivalent to `xmin=`, `ymin=`, or `bound_type='min_position', bound=`: - The coordinate of the innermost bend (D's bend). - The x/y versions throw an error if they do not match the port axis (for debug) - - `pmax=`, `xmax=`, `ymax=`, or `bound_type='max_position', bound=`: - The coordinate of the outermost bend (A's bend). - The x/y versions throw an error if they do not match the port axis (for debug) - - `bound_type='min_past_furthest', bound=`: - The distance between furthest out-port (B) and the innermost bend (D's bend). - - If `ccw=None`, final output positions (along the input axis) of all wires will be - identical (i.e. wires will all be cut off evenly). In this case, `spacing=None` is - required. In this case, `emin=` and `emax=` are equivalent to each other, and - `pmin=`, `pmax=`, `xmin=`, etc. are also equivalent to each other. - - If using `RenderPather`, `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec: The names of the ports which are to be routed. - ccw: If `None`, the outputs should be along the same axis as the inputs. - Otherwise, cast to bool and turn 90 degrees counterclockwise if `True` - and clockwise otherwise. - spacing: Center-to-center distance between output ports along the input port's axis. - Must be provided if (and only if) `ccw` is not `None`. - set_rotation: If the provided ports have `rotation=None`, this can be used - to set a rotation for them. - - Returns: - self - - Raises: - BuildError if the implied length for any wire is too close to fit the bend - (if a bend is requested). - BuildError if `xmin`/`xmax` or `ymin`/`ymax` is specified but does not - match the axis of `portspec`. - BuildError if an incorrect bound type or spacing is specified. - """ - if self._dead: - logger.error('Skipping mpath() since device is dead') - return self - - bound_types = set() - if 'bound_type' in kwargs: - bound_types.add(kwargs.pop('bound_type')) - bound = kwargs.pop('bound') - for bt in ('emin', 'emax', 'pmin', 'pmax', 'xmin', 'xmax', 'ymin', 'ymax', 'min_past_furthest'): - if bt in kwargs: - bound_types.add(bt) - bound = kwargs.pop(bt) - - if not bound_types: - raise BuildError('No bound type specified for mpath') - if len(bound_types) > 1: - raise BuildError(f'Too many bound types specified for mpath: {bound_types}') - bound_type = tuple(bound_types)[0] - - if isinstance(portspec, str): - portspec = [portspec] - ports = self.pattern[tuple(portspec)] - - extensions = ell(ports, ccw, spacing=spacing, bound=bound, bound_type=bound_type, set_rotation=set_rotation) - - #if container: - # assert not getattr(self, 'render'), 'Containers not implemented for RenderPather' - # bld = self.interface(source=ports, library=self.library, tools=self.tools) - # for port_name, length in extensions.items(): - # bld.path(port_name, ccw, length, **kwargs) - # self.library[container] = bld.pattern - # self.plug(Abstract(container, bld.pattern.ports), {sp: 'in_' + sp for sp in ports}) # TODO safe to use 'in_'? - #else: - for port_name, length in extensions.items(): - self.path(port_name, ccw, length, **kwargs) - return self - - # TODO def bus_join()? - - def flatten(self) -> Self: - """ - Flatten the contained pattern, using the contained library to resolve references. - - Returns: - self - """ - self.pattern.flatten(self.library) - return self - - def at(self, portspec: str | Iterable[str]) -> 'PortPather': - return PortPather(portspec, self) - - -class PortPather: - """ - Port state manager - - This class provides a convenient way to perform multiple pathing operations on a - set of ports without needing to repeatedly pass their names. - """ - ports: list[str] - pather: PatherMixin - - def __init__(self, ports: str | Iterable[str], pather: PatherMixin) -> None: - self.ports = [ports] if isinstance(ports, str) else list(ports) - self.pather = pather - - # - # Delegate to pather - # - def retool(self, tool: Tool) -> Self: - self.pather.retool(tool, keys=self.ports) - return self - - @contextmanager - def toolctx(self, tool: Tool) -> Iterator[Self]: - with self.pather.toolctx(tool, keys=self.ports): - yield self - - def path(self, *args, **kwargs) -> Self: - if len(self.ports) > 1: - logger.warning('Use path_each() when pathing multiple ports independently') - for port in self.ports: - self.pather.path(port, *args, **kwargs) - return self - - def path_each(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.path(port, *args, **kwargs) - return self - - def pathS(self, *args, **kwargs) -> Self: - if len(self.ports) > 1: - logger.warning('Use pathS_each() when pathing multiple ports independently') - for port in self.ports: - self.pather.pathS(port, *args, **kwargs) - return self - - def pathS_each(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.pathS(port, *args, **kwargs) - return self - - def path_to(self, *args, **kwargs) -> Self: - if len(self.ports) > 1: - logger.warning('Use path_each_to() when pathing multiple ports independently') - for port in self.ports: - self.pather.path_to(port, *args, **kwargs) - return self - - def path_each_to(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather.path_to(port, *args, **kwargs) - return self - - def mpath(self, *args, **kwargs) -> Self: - self.pather.mpath(self.ports, *args, **kwargs) - return self - - def path_into(self, *args, **kwargs) -> Self: - """ Path_into, using the current port as the source """ - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit path_into() with {len(self.ports)} (>1) ports.') - self.pather.path_into(self.ports[0], *args, **kwargs) - return self - - def path_from(self, *args, **kwargs) -> Self: - """ Path_into, using the current port as the destination """ - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit path_from() with {len(self.ports)} (>1) ports.') - thru = kwargs.pop('thru', None) - self.pather.path_into(args[0], self.ports[0], *args[1:], **kwargs) - if thru is not None: - self.rename_from(thru) - return self - - def plug( - self, - other: Abstract | str, - other_port: str, - *args, - **kwargs, - ) -> Self: - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit plug() with {len(self.ports)} ports.' - 'Use the pather or pattern directly to plug multiple ports.') - self.pather.plug(other, {self.ports[0]: other_port}, *args, **kwargs) - return self - - def plugged(self, other_port: str) -> Self: - if len(self.ports) > 1: - raise BuildError(f'Unable use implicit plugged() with {len(self.ports)} (>1) ports.') - self.pather.plugged({self.ports[0]: other_port}) - return self - - # - # Delegate to port - # - def set_ptype(self, ptype: str) -> Self: - for port in self.ports: - self.pather[port].set_ptype(ptype) - return self - - def translate(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather[port].translate(*args, **kwargs) - return self - - def mirror(self, *args, **kwargs) -> Self: - for port in self.ports: - self.pather[port].mirror(*args, **kwargs) - return self - - def rotate(self, rotation: float) -> Self: - for port in self.ports: - self.pather[port].rotate(rotation) - return self - - def set_rotation(self, rotation: float | None) -> Self: - for port in self.ports: - self.pather[port].set_rotation(rotation) - return self - - def rename_to(self, new_name: str) -> Self: - if len(self.ports) > 1: - BuildError('Use rename_ports() for >1 port') - self.pather.rename_ports({self.ports[0]: new_name}) - self.ports[0] = new_name - return self - - def rename_from(self, old_name: str) -> Self: - if len(self.ports) > 1: - BuildError('Use rename_ports() for >1 port') - self.pather.rename_ports({old_name: self.ports[0]}) - return self - - def rename_ports(self, name_map: dict[str, str | None]) -> Self: - self.pather.rename_ports(name_map) - self.ports = [mm for mm in [name_map.get(pp, pp) for pp in self.ports] if mm is not None] - return self - - def add_ports(self, ports: Iterable[str]) -> Self: - ports = list(ports) - conflicts = set(ports) & set(self.ports) - if conflicts: - raise BuildError(f'ports {conflicts} already selected') - self.ports += ports - return self - - def add_port(self, port: str, index: int | None = None) -> Self: - if port in self.ports: - raise BuildError(f'{port=} already selected') - if index is not None: - self.ports.insert(index, port) - else: - self.ports.append(port) - return self - - def drop_port(self, port: str) -> Self: - if port not in self.ports: - raise BuildError(f'{port=} already not selected') - self.ports = [pp for pp in self.ports if pp != port] - return self - - def into_copy(self, new_name: str, src: str | None = None) -> Self: - """ Copy a port and replace it with the copy """ - if not self.ports: - raise BuildError('Have no ports to copy') - if len(self.ports) == 1: - src = self.ports[0] - elif src is None: - raise BuildError('Must specify src when >1 port is available') - if src not in self.ports: - raise BuildError(f'{src=} not available') - self.pather.ports[new_name] = self.pather[src].copy() - self.ports = [(new_name if pp == src else pp) for pp in self.ports] - return self - - def save_copy(self, new_name: str, src: str | None = None) -> Self: - """ Copy a port and but keep using the original """ - if not self.ports: - raise BuildError('Have no ports to copy') - if len(self.ports) == 1: - src = self.ports[0] - elif src is None: - raise BuildError('Must specify src when >1 port is available') - if src not in self.ports: - raise BuildError(f'{src=} not available') - self.pather.ports[new_name] = self.pather[src].copy() - return self - - @overload - def delete(self, name: None) -> None: ... - - @overload - def delete(self, name: str) -> Self: ... - - def delete(self, name: str | None = None) -> Self | None: - if name is None: - for pp in self.ports: - del self.pather.ports[pp] - return None - del self.pather.ports[name] - self.ports = [pp for pp in self.ports if pp != name] - return self - diff --git a/masque/builder/port_utils.py b/masque/builder/port_utils.py new file mode 100644 index 0000000..5746dde --- /dev/null +++ b/masque/builder/port_utils.py @@ -0,0 +1,112 @@ +""" +Functions for writing port data into a Pattern (`dev2pat`) and retrieving it (`pat2dev`). + + These use the format 'name:ptype angle_deg' written into labels, which are placed at +the port locations. This particular approach is just a sensible default; feel free to +to write equivalent functions for your own format or alternate storage methods. +""" +from typing import Sequence +import logging + +import numpy + +from ..pattern import Pattern +from ..label import Label +from ..utils import rotation_matrix_2d, layer_t +from .devices import Device, Port + + +logger = logging.getLogger(__name__) + + +def dev2pat(device: Device, layer: layer_t) -> Pattern: + """ + Place a text label at each port location, specifying the port data in the format + 'name:ptype angle_deg' + + This can be used to debug port locations or to automatically generate ports + when reading in a GDS file. + + NOTE that `device` is modified by this function, and `device.pattern` is returned. + + Args: + device: The device which is to have its ports labeled. MODIFIED in-place. + layer: The layer on which the labels will be placed. + + Returns: + `device.pattern` + """ + for name, port in device.ports.items(): + if port.rotation is None: + angle_deg = numpy.inf + else: + angle_deg = numpy.rad2deg(port.rotation) + device.pattern.labels += [ + Label(string=f'{name}:{port.ptype} {angle_deg:g}', layer=layer, offset=port.offset) + ] + return device.pattern + + +def pat2dev( + pattern: Pattern, + layers: Sequence[layer_t], + max_depth: int = 999_999, + skip_subcells: bool = True, + ) -> Device: + """ + Examine `pattern` for labels specifying port info, and use that info + to build a `Device` object. + + Labels are assumed to be placed at the port locations, and have the format + 'name:ptype angle_deg' + + Args: + pattern: Pattern object to scan for labels. + layers: Search for labels on all the given layers. + max_depth: Maximum hierarcy depth to search. Default 999_999. + Reduce this to 0 to avoid ever searching subcells. + skip_subcells: If port labels are found at a given hierarcy level, + do not continue searching at deeper levels. This allows subcells + to contain their own port info (and thus become their own Devices). + Default True. + + Returns: + The constructed Device object. Port labels are not removed from the pattern. + """ + ports = {} # Note: could do a list here, if they're not unique + annotated_cells = set() + def find_ports_each(pat, hierarchy, transform, memo) -> Pattern: + if len(hierarchy) > max_depth - 1: + return pat + + if skip_subcells and any(parent in annotated_cells for parent in hierarchy): + return pat + + labels = [ll for ll in pat.labels if ll.layer in layers] + + if len(labels) == 0: + return pat + + if skip_subcells: + annotated_cells.add(pat) + + mirr_factor = numpy.array((1, -1)) ** transform[3] + rot_matrix = rotation_matrix_2d(transform[2]) + for label in labels: + name, property_string = label.string.split(':') + properties = property_string.split(' ') + ptype = properties[0] + angle_deg = float(properties[1]) if len(ptype) else 0 + + xy_global = transform[:2] + rot_matrix @ (label.offset * mirr_factor) + angle = numpy.deg2rad(angle_deg) * mirr_factor[0] * mirr_factor[1] + transform[2] + + if name in ports: + logger.info(f'Duplicate port {name} in pattern {pattern.name}') + + ports[name] = Port(offset=xy_global, rotation=angle, ptype=ptype) + + return pat + + pattern.dfs(visit_before=find_ports_each, transform=True) + return Device(pattern, ports) diff --git a/masque/builder/renderpather.py b/masque/builder/renderpather.py deleted file mode 100644 index 7f18e77..0000000 --- a/masque/builder/renderpather.py +++ /dev/null @@ -1,646 +0,0 @@ -""" -Pather with batched (multi-step) rendering -""" -from typing import Self -from collections.abc import Sequence, Mapping, MutableMapping, Iterable -import copy -import logging -from collections import defaultdict -from functools import wraps -from pprint import pformat - -from numpy import pi -from numpy.typing import ArrayLike - -from ..pattern import Pattern -from ..library import ILibrary, TreeView -from ..error import BuildError -from ..ports import PortList, Port -from ..abstract import Abstract -from ..utils import SupportsBool -from .tools import Tool, RenderStep -from .pather_mixin import PatherMixin - - -logger = logging.getLogger(__name__) - - -class RenderPather(PatherMixin): - """ - `RenderPather` is an alternative to `Pather` which uses the `path`/`path_to`/`mpath` - functions to plan out wire paths without incrementally generating the layout. Instead, - it waits until `render` is called, at which point it draws all the planned segments - simultaneously. This allows it to e.g. draw each wire using a single `Path` or - `Polygon` shape instead of multiple rectangles. - - `RenderPather` calls out to `Tool.planL` and `Tool.render` to provide tool-specific - dimensions and build the final geometry for each wire. `Tool.planL` provides the - output port data (relative to the input) for each segment. The tool, input and output - ports are placed into a `RenderStep`, and a sequence of `RenderStep`s is stored for - each port. When `render` is called, it bundles `RenderStep`s into batches which use - the same `Tool`, and passes each batch to the relevant tool's `Tool.render` to build - the geometry. - - See `Pather` for routing examples. After routing is complete, `render` must be called - to generate the final geometry. - """ - __slots__ = ('pattern', 'library', 'paths', 'tools', '_dead', ) - - pattern: Pattern - """ Layout of this device """ - - library: ILibrary - """ Library from which patterns should be referenced """ - - _dead: bool - """ If True, plug()/place() are skipped (for debugging) """ - - paths: defaultdict[str, list[RenderStep]] - """ Per-port list of operations, to be used by `render` """ - - tools: dict[str | None, Tool] - """ - Tool objects are used to dynamically generate new single-use Devices - (e.g wires or waveguides) to be plugged into this device. - """ - - @property - def ports(self) -> dict[str, Port]: - return self.pattern.ports - - @ports.setter - def ports(self, value: dict[str, Port]) -> None: - self.pattern.ports = value - - def __init__( - self, - library: ILibrary, - *, - pattern: Pattern | None = None, - ports: str | Mapping[str, Port] | None = None, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - name: str | None = None, - ) -> None: - """ - Args: - library: The library from which referenced patterns will be taken, - and where new patterns (e.g. generated by the `tools`) will be placed. - pattern: The pattern which will be modified by subsequent operations. - If `None` (default), a new pattern is created. - ports: Allows specifying the initial set of ports, if `pattern` does - not already have any ports (or is not provided). May be a string, - in which case it is interpreted as a name in `library`. - Default `None` (no ports). - tools: A mapping of {port: tool} which specifies what `Tool` should be used - to generate waveguide or wire segments when `path`/`path_to`/`mpath` - are called. Relies on `Tool.planL` and `Tool.render` implementations. - name: If specified, `library[name]` is set to `self.pattern`. - """ - self._dead = False - self.paths = defaultdict(list) - self.library = library - if pattern is not None: - self.pattern = pattern - else: - self.pattern = Pattern() - - if ports is not None: - if self.pattern.ports: - raise BuildError('Ports supplied for pattern with pre-existing ports!') - if isinstance(ports, str): - ports = library.abstract(ports).ports - - self.pattern.ports.update(copy.deepcopy(dict(ports))) - - if name is not None: - library[name] = self.pattern - - if tools is None: - self.tools = {} - elif isinstance(tools, Tool): - self.tools = {None: tools} - else: - self.tools = dict(tools) - - @classmethod - def interface( - cls: type['RenderPather'], - source: PortList | Mapping[str, Port] | str, - *, - library: ILibrary | None = None, - tools: Tool | MutableMapping[str | None, Tool] | None = None, - in_prefix: str = 'in_', - out_prefix: str = '', - port_map: dict[str, str] | Sequence[str] | None = None, - name: str | None = None, - ) -> 'RenderPather': - """ - Wrapper for `Pattern.interface()`, which returns a RenderPather instead. - - Args: - source: A collection of ports (e.g. Pattern, Builder, or dict) - from which to create the interface. May be a pattern name if - `library` is provided. - library: Library from which existing patterns should be referenced, - and to which the new one should be added (if named). If not provided, - `source.library` must exist and will be used. - tools: `Tool`s which will be used by the pather for generating new wires - or waveguides (via `path`/`path_to`/`mpath`). - in_prefix: Prepended to port names for newly-created ports with - reversed directions compared to the current device. - out_prefix: Prepended to port names for ports which are directly - copied from the current device. - port_map: Specification for ports to copy into the new device: - - If `None`, all ports are copied. - - If a sequence, only the listed ports are copied - - If a mapping, the listed ports (keys) are copied and - renamed (to the values). - - Returns: - The new `RenderPather`, with an empty pattern and 2x as many ports as - listed in port_map. - - Raises: - `PortError` if `port_map` contains port names not present in the - current device. - `PortError` if applying the prefixes results in duplicate port - names. - """ - if library is None: - if hasattr(source, 'library') and isinstance(source.library, ILibrary): - library = source.library - else: - raise BuildError('No library provided (and not present in `source.library`') - - if tools is None and hasattr(source, 'tools') and isinstance(source.tools, dict): - tools = source.tools - - if isinstance(source, str): - source = library.abstract(source).ports - - pat = Pattern.interface(source, in_prefix=in_prefix, out_prefix=out_prefix, port_map=port_map) - new = RenderPather(library=library, pattern=pat, name=name, tools=tools) - return new - - def __repr__(self) -> str: - s = f'' - return s - - def plug( - self, - other: Abstract | str | Pattern | TreeView, - map_in: dict[str, str], - map_out: dict[str, str | None] | None = None, - *, - mirrored: bool = False, - thru: bool | str = True, - set_rotation: bool | None = None, - append: bool = False, - ok_connections: Iterable[tuple[str, str]] = (), - ) -> Self: - """ - Wrapper for `Pattern.plug` which adds a `RenderStep` with opcode 'P' - for any affected ports. This separates any future `RenderStep`s on the - same port into a new batch, since the plugged device interferes with drawing. - - Args: - other: An `Abstract`, string, or `Pattern` describing the device to be instatiated. - map_in: dict of `{'self_port': 'other_port'}` mappings, specifying - port connections between the two devices. - map_out: dict of `{'old_name': 'new_name'}` mappings, specifying - new names for ports in `other`. - mirrored: Enables mirroring `other` across the x axis prior to - connecting any ports. - thru: If map_in specifies only a single port, `thru` provides a mechainsm - to avoid repeating the port name. Eg, for `map_in={'myport': 'A'}`, - - If True (default), and `other` has only two ports total, and map_out - doesn't specify a name for the other port, its name is set to the key - in `map_in`, i.e. 'myport'. - - If a string, `map_out[thru]` is set to the key in `map_in` (i.e. 'myport'). - An error is raised if that entry already exists. - - This makes it easy to extend a pattern with simple 2-port devices - (e.g. wires) without providing `map_out` each time `plug` is - called. See "Examples" above for more info. Default `True`. - set_rotation: If the necessary rotation cannot be determined from - the ports being connected (i.e. all pairs have at least one - port with `rotation=None`), `set_rotation` must be provided - to indicate how much `other` should be rotated. Otherwise, - `set_rotation` must remain `None`. - append: If `True`, `other` is appended instead of being referenced. - Note that this does not flatten `other`, so its refs will still - be refs (now inside `self`). - ok_connections: Set of "allowed" ptype combinations. Identical - ptypes are always allowed to connect, as is `'unk'` with - any other ptypte. Non-allowed ptype connections will emit a - warning. Order is ignored, i.e. `(a, b)` is equivalent to - `(b, a)`. - - - Returns: - self - - Raises: - `PortError` if any ports specified in `map_in` or `map_out` do not - exist in `self.ports` or `other_names`. - `PortError` if there are any duplicate names after `map_in` and `map_out` - are applied. - `PortError` if the specified port mapping is not achieveable (the ports - do not line up) - """ - if self._dead: - logger.error('Skipping plug() since device is dead') - return self - - other_tgt: Pattern | Abstract - if isinstance(other, str): - other_tgt = self.library.abstract(other) - if append and isinstance(other, Abstract): - other_tgt = self.library[other.name] - - # get rid of plugged ports - for kk in map_in: - if kk in self.paths: - self.paths[kk].append(RenderStep('P', None, self.ports[kk].copy(), self.ports[kk].copy(), None)) - - plugged = map_in.values() - for name, port in other_tgt.ports.items(): - if name in plugged: - continue - new_name = map_out.get(name, name) if map_out is not None else name - if new_name is not None and new_name in self.paths: - self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) - - self.pattern.plug( - other = other_tgt, - map_in = map_in, - map_out = map_out, - mirrored = mirrored, - thru = thru, - set_rotation = set_rotation, - append = append, - ok_connections = ok_connections, - ) - - return self - - def place( - self, - other: Abstract | str, - *, - offset: ArrayLike = (0, 0), - rotation: float = 0, - pivot: ArrayLike = (0, 0), - mirrored: bool = False, - port_map: dict[str, str | None] | None = None, - skip_port_check: bool = False, - append: bool = False, - ) -> Self: - """ - Wrapper for `Pattern.place` which adds a `RenderStep` with opcode 'P' - for any affected ports. This separates any future `RenderStep`s on the - same port into a new batch, since the placed device interferes with drawing. - - Note that mirroring is applied before rotation; translation (`offset`) is applied last. - - Args: - other: An `Abstract` or `Pattern` describing the device to be instatiated. - offset: Offset at which to place the instance. Default (0, 0). - rotation: Rotation applied to the instance before placement. Default 0. - pivot: Rotation is applied around this pivot point (default (0, 0)). - Rotation is applied prior to translation (`offset`). - mirrored: Whether theinstance should be mirrored across the x axis. - Mirroring is applied before translation and rotation. - port_map: dict of `{'old_name': 'new_name'}` mappings, specifying - new names for ports in the instantiated pattern. New names can be - `None`, which will delete those ports. - skip_port_check: Can be used to skip the internal call to `check_ports`, - in case it has already been performed elsewhere. - append: If `True`, `other` is appended instead of being referenced. - Note that this does not flatten `other`, so its refs will still - be refs (now inside `self`). - - Returns: - self - - Raises: - `PortError` if any ports specified in `map_in` or `map_out` do not - exist in `self.ports` or `other.ports`. - `PortError` if there are any duplicate names after `map_in` and `map_out` - are applied. - """ - if self._dead: - logger.error('Skipping place() since device is dead') - return self - - other_tgt: Pattern | Abstract - if isinstance(other, str): - other_tgt = self.library.abstract(other) - if append and isinstance(other, Abstract): - other_tgt = self.library[other.name] - - for name, port in other_tgt.ports.items(): - new_name = port_map.get(name, name) if port_map is not None else name - if new_name is not None and new_name in self.paths: - self.paths[new_name].append(RenderStep('P', None, port.copy(), port.copy(), None)) - - self.pattern.place( - other = other_tgt, - offset = offset, - rotation = rotation, - pivot = pivot, - mirrored = mirrored, - port_map = port_map, - skip_port_check = skip_port_check, - append = append, - ) - - return self - - def plugged( - self, - connections: dict[str, str], - ) -> Self: - for aa, bb in connections.items(): - porta = self.ports[aa] - portb = self.ports[bb] - self.paths[aa].append(RenderStep('P', None, porta.copy(), porta.copy(), None)) - self.paths[bb].append(RenderStep('P', None, portb.copy(), portb.copy(), None)) - PortList.plugged(self, connections) - return self - - def path( - self, - portspec: str, - ccw: SupportsBool | None, - length: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Plan a "wire"/"waveguide" extending from the port `portspec`, with the aim - of traveling exactly `length` distance. - - The wire will travel `length` distance along the port's axis, an an unspecified - (tool-dependent) distance in the perpendicular direction. The output port will - be rotated (or not) based on the `ccw` parameter. - - `RenderPather.render` must be called after all paths have been fully planned. - - Args: - portspec: The name of the port into which the wire will be plugged. - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - length: The total distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - Raises: - BuildError if `distance` is too small to fit the bend (if a bend is present). - LibraryError if no valid name could be picked for the pattern. - """ - if self._dead: - logger.error('Skipping path() since device is dead') - return self - - port = self.pattern[portspec] - in_ptype = port.ptype - port_rot = port.rotation - assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()? - - tool = self.tools.get(portspec, self.tools[None]) - # ask the tool for bend size (fill missing dx or dy), check feasibility, and get out_ptype - out_port, data = tool.planL(ccw, length, in_ptype=in_ptype, **kwargs) - - # Update port - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - - step = RenderStep('L', tool, port.copy(), out_port.copy(), data) - self.paths[portspec].append(step) - - self.pattern.ports[portspec] = out_port.copy() - - if plug_into is not None: - self.plugged({portspec: plug_into}) - - return self - - def pathS( - self, - portspec: str, - length: float, - jog: float, - *, - plug_into: str | None = None, - **kwargs, - ) -> Self: - """ - Create an S-shaped "wire"/"waveguide" and `plug` it into the port `portspec`, with the aim - of traveling exactly `length` distance with an offset `jog` along the other axis (+ve jog is - left of direction of travel). - - The output port will have the same orientation as the source port (`portspec`). - - `RenderPather.render` must be called after all paths have been fully planned. - - This function attempts to use `tool.planS()`, but falls back to `tool.planL()` if the former - raises a NotImplementedError. - - Args: - portspec: The name of the port into which the wire will be plugged. - jog: Total manhattan distance perpendicular to the direction of travel. - Positive values are to the left of the direction of travel. - length: The total manhattan distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - plug_into: If not None, attempts to plug the wire's output port into the provided - port on `self`. - - Returns: - self - - Raises: - BuildError if `distance` is too small to fit the s-bend (for nonzero jog). - LibraryError if no valid name could be picked for the pattern. - """ - if self._dead: - logger.error('Skipping pathS() since device is dead') - return self - - port = self.pattern[portspec] - in_ptype = port.ptype - port_rot = port.rotation - assert port_rot is not None # TODO allow manually setting rotation for RenderPather.path()? - - tool = self.tools.get(portspec, self.tools[None]) - - # check feasibility, get output port and data - try: - out_port, data = tool.planS(length, jog, in_ptype=in_ptype, **kwargs) - except NotImplementedError: - # Fall back to drawing two L-bends - ccw0 = jog > 0 - kwargs_no_out = (kwargs | {'out_ptype': None}) - t_port0, _ = tool.planL( ccw0, length / 2, in_ptype=in_ptype, **kwargs_no_out) # TODO length/2 may fail with asymmetric ptypes - jog0 = Port((0, 0), 0).measure_travel(t_port0)[0][1] - t_port1, _ = tool.planL(not ccw0, abs(jog - jog0), in_ptype=t_port0.ptype, **kwargs) - jog1 = Port((0, 0), 0).measure_travel(t_port1)[0][1] - - kwargs_plug = kwargs | {'plug_into': plug_into} - self.path(portspec, ccw0, length - abs(jog1), **kwargs_no_out) - self.path(portspec, not ccw0, abs(jog - jog0), **kwargs_plug) - return self - - out_port.rotate_around((0, 0), pi + port_rot) - out_port.translate(port.offset) - step = RenderStep('S', tool, port.copy(), out_port.copy(), data) - self.paths[portspec].append(step) - self.pattern.ports[portspec] = out_port.copy() - - if plug_into is not None: - self.plugged({portspec: plug_into}) - return self - - - def render( - self, - append: bool = True, - ) -> Self: - """ - Generate the geometry which has been planned out with `path`/`path_to`/etc. - - Args: - append: If `True`, the rendered geometry will be directly appended to - `self.pattern`. Note that it will not be flattened, so if only one - layer of hierarchy is eliminated. - - Returns: - self - """ - lib = self.library - tool_port_names = ('A', 'B') - pat = Pattern() - - def render_batch(portspec: str, batch: list[RenderStep], append: bool) -> None: - assert batch[0].tool is not None - name = lib << batch[0].tool.render(batch, port_names=tool_port_names) - pat.ports[portspec] = batch[0].start_port.copy() - if append: - pat.plug(lib[name], {portspec: tool_port_names[0]}, append=append) - del lib[name] # NOTE if the rendered pattern has refs, those are now in `pat` but not flattened - else: - pat.plug(lib.abstract(name), {portspec: tool_port_names[0]}, append=append) - - for portspec, steps in self.paths.items(): - batch: list[RenderStep] = [] - for step in steps: - appendable_op = step.opcode in ('L', 'S', 'U') - same_tool = batch and step.tool == batch[0].tool - - # If we can't continue a batch, render it - if batch and (not appendable_op or not same_tool): - render_batch(portspec, batch, append) - batch = [] - - # batch is emptied already if we couldn't continue it - if appendable_op: - batch.append(step) - - # Opcodes which break the batch go below this line - if not appendable_op and portspec in pat.ports: - del pat.ports[portspec] - - #If the last batch didn't end yet - if batch: - render_batch(portspec, batch, append) - - self.paths.clear() - pat.ports.clear() - self.pattern.append(pat) - - return self - - def translate(self, offset: ArrayLike) -> Self: - """ - Translate the pattern and all ports. - - Args: - offset: (x, y) distance to translate by - - Returns: - self - """ - self.pattern.translate_elements(offset) - return self - - def rotate_around(self, pivot: ArrayLike, angle: float) -> Self: - """ - Rotate the pattern and all ports. - - Args: - angle: angle (radians, counterclockwise) to rotate by - pivot: location to rotate around - - Returns: - self - """ - self.pattern.rotate_around(pivot, angle) - return self - - def mirror(self, axis: int) -> Self: - """ - Mirror the pattern and all ports across the specified axis. - - Args: - axis: Axis to mirror across (x=0, y=1) - - Returns: - self - """ - self.pattern.mirror(axis) - return self - - def set_dead(self) -> Self: - """ - Disallows further changes through `plug()` or `place()`. - This is meant for debugging: - ``` - dev.plug(a, ...) - dev.set_dead() # added for debug purposes - dev.plug(b, ...) # usually raises an error, but now skipped - dev.plug(c, ...) # also skipped - dev.pattern.visualize() # shows the device as of the set_dead() call - ``` - - Returns: - self - """ - self._dead = True - return self - - @wraps(Pattern.label) - def label(self, *args, **kwargs) -> Self: - self.pattern.label(*args, **kwargs) - return self - - @wraps(Pattern.ref) - def ref(self, *args, **kwargs) -> Self: - self.pattern.ref(*args, **kwargs) - return self - - @wraps(Pattern.polygon) - def polygon(self, *args, **kwargs) -> Self: - self.pattern.polygon(*args, **kwargs) - return self - - @wraps(Pattern.rect) - def rect(self, *args, **kwargs) -> Self: - self.pattern.rect(*args, **kwargs) - return self - diff --git a/masque/builder/tools.py b/masque/builder/tools.py index 6bd7547..bd93f0c 100644 --- a/masque/builder/tools.py +++ b/masque/builder/tools.py @@ -1,1003 +1,22 @@ """ Tools are objects which dynamically generate simple single-use devices (e.g. wires or waveguides) - -# TODO document all tools """ -from typing import Literal, Any, Self -from collections.abc import Sequence, Callable -from abc import ABCMeta # , abstractmethod # TODO any way to make Tool ok with implementing only one method? -from dataclasses import dataclass +from typing import TYPE_CHECKING, Optional, Sequence -import numpy -from numpy.typing import NDArray -from numpy import pi - -from ..utils import SupportsBool, rotation_matrix_2d, layer_t -from ..ports import Port -from ..pattern import Pattern -from ..abstract import Abstract -from ..library import ILibrary, Library, SINGLE_USE_PREFIX -from ..error import BuildError - - -@dataclass(frozen=True, slots=True) -class RenderStep: - """ - Representation of a single saved operation, used by `RenderPather` and passed - to `Tool.render()` when `RenderPather.render()` is called. - """ - opcode: Literal['L', 'S', 'U', 'P'] - """ What operation is being performed. - L: planL (straight, optionally with a single bend) - S: planS (s-bend) - U: planU (u-bend) - P: plug - """ - - tool: 'Tool | None' - """ The current tool. May be `None` if `opcode='P'` """ - - start_port: Port - end_port: Port - - data: Any - """ Arbitrary tool-specific data""" - - def __post_init__(self) -> None: - if self.opcode != 'P' and self.tool is None: - raise BuildError('Got tool=None but the opcode is not "P"') +if TYPE_CHECKING: + from .devices import Device class Tool: - """ - Interface for path (e.g. wire or waveguide) generation. - - Note that subclasses may implement only a subset of the methods and leave others - unimplemented (e.g. in cases where they don't make sense or the required components - are impractical or unavailable). - """ def path( self, - ccw: SupportsBool | None, + ccw: Optional[bool], length: float, *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), + in_ptype: Optional[str] = None, + out_ptype: Optional[str] = None, + port_names: Sequence[str] = ('A', 'B'), **kwargs, - ) -> Library: - """ - Create a wire or waveguide that travels exactly `length` distance along the axis - of its input port. - - Used by `Pather` and `RenderPather`. - - The output port must be exactly `length` away along the input port's axis, but - may be placed an additional (unspecified) distance away along the perpendicular - direction. The output port should be rotated (or not) based on the value of - `ccw`. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. They should also be named `port_names[0]` and - `port_names[1]`, respectively. - - Args: - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - length: The total distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - port_names: The output pattern will have its input port named `port_names[0]` and - its output named `port_names[1]`. - kwargs: Custom tool-specific parameters. - - Returns: - A pattern tree containing the requested L-shaped (or straight) wire or waveguide - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ + ) -> 'Device': raise NotImplementedError(f'path() not implemented for {type(self)}') - def pathS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - """ - Create a wire or waveguide that travels exactly `length` distance along the axis - of its input port, and `jog` distance on the perpendicular axis. - `jog` is positive when moving left of the direction of travel (from input to ouput port). - - Used by `Pather` and `RenderPather`. - - The output port should be rotated to face the input port (i.e. plugging the device - into a port will move that port but keep its orientation). - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. They should also be named `port_names[0]` and - `port_names[1]`, respectively. - - Args: - length: The total distance from input to output, along the input's axis only. - jog: The total distance from input to output, along the second axis. Positive indicates - a leftward shift when moving from input to output port. - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - port_names: The output pattern will have its input port named `port_names[0]` and - its output named `port_names[1]`. - kwargs: Custom tool-specific parameters. - - Returns: - A pattern tree containing the requested S-shaped (or straight) wire or waveguide - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'path() not implemented for {type(self)}') - - def planL( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, Any]: - """ - Plan a wire or waveguide that travels exactly `length` distance along the axis - of its input port. - - Used by `RenderPather`. - - The output port must be exactly `length` away along the input port's axis, but - may be placed an additional (unspecified) distance away along the perpendicular - direction. The output port should be rotated (or not) based on the value of - `ccw`. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. - - Args: - ccw: If `None`, the output should be along the same axis as the input. - Otherwise, cast to bool and turn counterclockwise if True - and clockwise otherwise. - length: The total distance from input to output, along the input's axis only. - (There may be a tool-dependent offset along the other axis.) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - kwargs: Custom tool-specific parameters. - - Returns: - The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. - Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'planL() not implemented for {type(self)}') - - def planS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, Any]: - """ - Plan a wire or waveguide that travels exactly `length` distance along the axis - of its input port and `jog` distance along the perpendicular axis (i.e. an S-bend). - - Used by `RenderPather`. - - The output port must have an orientation rotated by pi from the input port. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. - - Args: - length: The total distance from input to output, along the input's axis only. - jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a rightwards shift (i.e. clockwise bend followed - by a counterclockwise bend) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - kwargs: Custom tool-specific parameters. - - Returns: - The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. - Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'planS() not implemented for {type(self)}') - - def planU( - self, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, Any]: - """ - # NOTE: TODO: U-bend is WIP; this interface may change in the future. - - Plan a wire or waveguide that travels exactly `jog` distance along the axis - perpendicular to its input port (i.e. a U-bend). - - Used by `RenderPather`. - - The output port must have an orientation identical to the input port. - - The input and output ports should be compatible with `in_ptype` and - `out_ptype`, respectively. - - Args: - jog: The total offset from the input to output, along the perpendicular axis. - A positive number implies a leftwards shift (i.e. counterclockwise bend - followed by a clockwise bend) - in_ptype: The `ptype` of the port into which this wire's input will be `plug`ged. - out_ptype: The `ptype` of the port into which this wire's output will be `plug`ged. - kwargs: Custom tool-specific parameters. - - Returns: - The calculated output `Port` for the wire, assuming an input port at (0, 0) with rotation 0. - Any tool-specifc data, to be stored in `RenderStep.data`, for use during rendering. - - Raises: - BuildError if an impossible or unsupported geometry is requested. - """ - raise NotImplementedError(f'planU() not implemented for {type(self)}') - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), # noqa: ARG002 (unused) - **kwargs, # noqa: ARG002 (unused) - ) -> ILibrary: - """ - Render the provided `batch` of `RenderStep`s into geometry, returning a tree - (a Library with a single topcell). - - Args: - batch: A sequence of `RenderStep` objects containing the ports and data - provided by this tool's `planL`/`planS`/`planU` functions. - port_names: The topcell's input and output ports should be named - `port_names[0]` and `port_names[1]` respectively. - kwargs: Custom tool-specific parameters. - """ - assert not batch or batch[0].tool == self - raise NotImplementedError(f'render() not implemented for {type(self)}') - - -abstract_tuple_t = tuple[Abstract, str, str] - - -@dataclass -class SimpleTool(Tool, metaclass=ABCMeta): - """ - A simple tool which relies on a single pre-rendered `bend` pattern, a function - for generating straight paths, and a table of pre-rendered `transitions` for converting - from non-native ptypes. - """ - straight: tuple[Callable[[float], Pattern] | Callable[[float], Library], str, str] - """ `create_straight(length: float), in_port_name, out_port_name` """ - - bend: abstract_tuple_t # Assumed to be clockwise - """ `clockwise_bend_abstract, in_port_name, out_port_name` """ - - default_out_ptype: str - """ Default value for out_ptype """ - - mirror_bend: bool = True - """ Whether a clockwise bend should be mirrored (vs rotated) to get a ccw bend """ - - @dataclass(frozen=True, slots=True) - class LData: - """ Data for planL """ - straight_length: float - straight_kwargs: dict[str, Any] - ccw: SupportsBool | None - - def planL( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, # noqa: ARG002 (unused) - out_ptype: str | None = None, # noqa: ARG002 (unused) - **kwargs, # noqa: ARG002 (unused) - ) -> tuple[Port, LData]: - if ccw is not None: - bend, bport_in, bport_out = self.bend - - angle_in = bend.ports[bport_in].rotation - angle_out = bend.ports[bport_out].rotation - assert angle_in is not None - assert angle_out is not None - - bend_dxy = rotation_matrix_2d(-angle_in) @ ( - bend.ports[bport_out].offset - - bend.ports[bport_in].offset - ) - - bend_angle = angle_out - angle_in - - if bool(ccw): - bend_dxy[1] *= -1 - bend_angle *= -1 - else: - bend_dxy = numpy.zeros(2) - bend_angle = pi - - if ccw is not None: - out_ptype_actual = bend.ports[bport_out].ptype - else: - out_ptype_actual = self.default_out_ptype - - straight_length = length - bend_dxy[0] - bend_run = bend_dxy[1] - - if straight_length < 0: - raise BuildError( - f'Asked to draw L-path with total length {length:,g}, shorter than required bends ({bend_dxy[0]:,})' - ) - - data = self.LData(straight_length, kwargs, ccw) - out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) - return out_port, data - - def _renderL( - self, - data: LData, - tree: ILibrary, - port_names: tuple[str, str], - straight_kwargs: dict[str, Any], - ) -> ILibrary: - """ - Render an L step into a preexisting tree - """ - pat = tree.top_pattern() - gen_straight, sport_in, _sport_out = self.straight - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = gen_straight(data.straight_length, **(straight_kwargs | data.straight_kwargs)) - pmap = {port_names[1]: sport_in} - if isinstance(straight_pat_or_tree, Pattern): - straight_pat = straight_pat_or_tree - pat.plug(straight_pat, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.ccw is not None: - bend, bport_in, bport_out = self.bend - mirrored = self.mirror_bend and bool(data.ccw) - inport = bport_in if (self.mirror_bend or not data.ccw) else bport_out - pat.plug(bend, {port_names[1]: inport}, mirrored=mirrored) - return tree - - def path( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planL( - ccw, - length, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> ILibrary: - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=(port_names[0], port_names[1])) - - for step in batch: - assert step.tool == self - if step.opcode == 'L': - self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree - - -@dataclass -class AutoTool(Tool, metaclass=ABCMeta): - """ - A simple tool which relies on a single pre-rendered `bend` pattern, a function - for generating straight paths, and a table of pre-rendered `transitions` for converting - from non-native ptypes. - """ - - @dataclass(frozen=True, slots=True) - class Straight: - """ Description of a straight-path generator """ - ptype: str - fn: Callable[[float], Pattern] | Callable[[float], Library] - in_port_name: str - out_port_name: str - length_range: tuple[float, float] = (0, numpy.inf) - - @dataclass(frozen=True, slots=True) - class SBend: - """ Description of an s-bend generator """ - ptype: str - - fn: Callable[[float], Pattern] | Callable[[float], Library] - """ - Generator function. `jog` (only argument) is assumed to be left (ccw) relative to travel - and may be negative for a jog in the opposite direction. Won't be called if jog=0. - """ - - in_port_name: str - out_port_name: str - jog_range: tuple[float, float] = (0, numpy.inf) - - @dataclass(frozen=True, slots=True) - class Bend: - """ Description of a pre-rendered bend """ - abstract: Abstract - in_port_name: str - out_port_name: str - clockwise: bool = True # Is in-to-out clockwise? - mirror: bool = True # Should we mirror to get the other rotation? - - @property - def in_port(self) -> Port: - return self.abstract.ports[self.in_port_name] - - @property - def out_port(self) -> Port: - return self.abstract.ports[self.out_port_name] - - @dataclass(frozen=True, slots=True) - class Transition: - """ Description of a pre-rendered transition """ - abstract: Abstract - their_port_name: str - our_port_name: str - - @property - def our_port(self) -> Port: - return self.abstract.ports[self.our_port_name] - - @property - def their_port(self) -> Port: - return self.abstract.ports[self.their_port_name] - - def reversed(self) -> Self: - return type(self)(self.abstract, self.our_port_name, self.their_port_name) - - @dataclass(frozen=True, slots=True) - class LData: - """ Data for planL """ - straight_length: float - straight: 'AutoTool.Straight' - straight_kwargs: dict[str, Any] - ccw: SupportsBool | None - bend: 'AutoTool.Bend | None' - in_transition: 'AutoTool.Transition | None' - b_transition: 'AutoTool.Transition | None' - out_transition: 'AutoTool.Transition | None' - - @dataclass(frozen=True, slots=True) - class SData: - """ Data for planS """ - straight_length: float - straight: 'AutoTool.Straight' - gen_kwargs: dict[str, Any] - jog_remaining: float - sbend: 'AutoTool.SBend' - in_transition: 'AutoTool.Transition | None' - b_transition: 'AutoTool.Transition | None' - out_transition: 'AutoTool.Transition | None' - - straights: list[Straight] - """ List of straight-generators to choose from, in order of priority """ - - bends: list[Bend] - """ List of bends to choose from, in order of priority """ - - sbends: list[SBend] - """ List of S-bend generators to choose from, in order of priority """ - - transitions: dict[tuple[str, str], Transition] - """ `{(external_ptype, internal_ptype): Transition, ...}` """ - - default_out_ptype: str - """ Default value for out_ptype """ - - def add_complementary_transitions(self) -> Self: - for iioo in list(self.transitions.keys()): - ooii = (iioo[1], iioo[0]) - self.transitions.setdefault(ooii, self.transitions[iioo].reversed()) - return self - - @staticmethod - def _bend2dxy(bend: Bend, ccw: SupportsBool | None) -> tuple[NDArray[numpy.float64], float]: - if ccw is None: - return numpy.zeros(2), pi - bend_dxy, bend_angle = bend.in_port.measure_travel(bend.out_port) - assert bend_angle is not None - if bool(ccw): - bend_dxy[1] *= -1 - bend_angle *= -1 - return bend_dxy, bend_angle - - @staticmethod - def _sbend2dxy(sbend: SBend, jog: float) -> NDArray[numpy.float64]: - if numpy.isclose(jog, 0): - return numpy.zeros(2) - - sbend_pat_or_tree = sbend.fn(abs(jog)) - sbpat = sbend_pat_or_tree if isinstance(sbend_pat_or_tree, Pattern) else sbend_pat_or_tree.top_pattern() - dxy, _ = sbpat[sbend.in_port_name].measure_travel(sbpat[sbend.out_port_name]) - return dxy - - @staticmethod - def _itransition2dxy(in_transition: Transition | None) -> NDArray[numpy.float64]: - if in_transition is None: - return numpy.zeros(2) - dxy, _ = in_transition.their_port.measure_travel(in_transition.our_port) - return dxy - - @staticmethod - def _otransition2dxy(out_transition: Transition | None, bend_angle: float) -> NDArray[numpy.float64]: - if out_transition is None: - return numpy.zeros(2) - orot = out_transition.our_port.rotation - assert orot is not None - otrans_dxy = rotation_matrix_2d(pi - orot - bend_angle) @ (out_transition.their_port.offset - out_transition.our_port.offset) - return otrans_dxy - - def planL( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, LData]: - - success = False - for straight in self.straights: - for bend in self.bends: - bend_dxy, bend_angle = self._bend2dxy(bend, ccw) - - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - out_ptype_pair = ( - 'unk' if out_ptype is None else out_ptype, - straight.ptype if ccw is None else bend.out_port.ptype - ) - out_transition = self.transitions.get(out_ptype_pair, None) - otrans_dxy = self._otransition2dxy(out_transition, bend_angle) - - b_transition = None - if ccw is not None and bend.in_port.ptype != straight.ptype: - b_transition = self.transitions.get((bend.in_port.ptype, straight.ptype), None) - btrans_dxy = self._itransition2dxy(b_transition) - - straight_length = length - bend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] - bend_run = bend_dxy[1] + itrans_dxy[1] + btrans_dxy[1] + otrans_dxy[1] - success = straight.length_range[0] <= straight_length < straight.length_range[1] - if success: - break - if success: - break - else: - # Failed to break - raise BuildError( - f'Asked to draw L-path with total length {length:,g}, shorter than required bends and transitions:\n' - f'bend: {bend_dxy[0]:,g} in_trans: {itrans_dxy[0]:,g}\n' - f'out_trans: {otrans_dxy[0]:,g} bend_trans: {btrans_dxy[0]:,g}' - ) - - if out_transition is not None: - out_ptype_actual = out_transition.their_port.ptype - elif ccw is not None: - out_ptype_actual = bend.out_port.ptype - elif not numpy.isclose(straight_length, 0): - out_ptype_actual = straight.ptype - else: - out_ptype_actual = self.default_out_ptype - - data = self.LData(straight_length, straight, kwargs, ccw, bend, in_transition, b_transition, out_transition) - out_port = Port((length, bend_run), rotation=bend_angle, ptype=out_ptype_actual) - return out_port, data - - def _renderL( - self, - data: LData, - tree: ILibrary, - port_names: tuple[str, str], - straight_kwargs: dict[str, Any], - ) -> ILibrary: - """ - Render an L step into a preexisting tree - """ - pat = tree.top_pattern() - if data.in_transition: - pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = data.straight.fn(data.straight_length, **(straight_kwargs | data.straight_kwargs)) - pmap = {port_names[1]: data.straight.in_port_name} - if isinstance(straight_pat_or_tree, Pattern): - pat.plug(straight_pat_or_tree, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.b_transition: - pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) - if data.ccw is not None: - bend = data.bend - assert bend is not None - mirrored = bend.mirror and (bool(data.ccw) == bend.clockwise) - inport = bend.in_port_name if (bend.mirror or bool(data.ccw) != bend.clockwise) else bend.out_port_name - pat.plug(bend.abstract, {port_names[1]: inport}, mirrored=mirrored) - if data.out_transition: - pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) - return tree - - def path( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planL( - ccw, - length, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderL(data=data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - return tree - - def planS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - **kwargs, - ) -> tuple[Port, Any]: - - success = False - for straight in self.straights: - for sbend in self.sbends: - out_ptype_pair = ( - 'unk' if out_ptype is None else out_ptype, - straight.ptype if numpy.isclose(jog, 0) else sbend.ptype - ) - out_transition = self.transitions.get(out_ptype_pair, None) - otrans_dxy = self._otransition2dxy(out_transition, pi) - - # Assume we'll need a straight segment with transitions, then discard them if they don't fit - # We do this before generating the s-bend because the transitions might have some dy component - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, straight.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - b_transition = None - if not numpy.isclose(jog, 0) and sbend.ptype != straight.ptype: - b_transition = self.transitions.get((sbend.ptype, straight.ptype), None) - btrans_dxy = self._itransition2dxy(b_transition) - - if length > itrans_dxy[0] + btrans_dxy[0] + otrans_dxy[0]: - # `if` guard to avoid unnecessary calls to `_sbend2dxy()`, which calls `sbend.fn()` - # note some S-bends may have 0 length, so we can't be more restrictive - jog_remaining = jog - itrans_dxy[1] - btrans_dxy[1] - otrans_dxy[1] - sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - straight_length = length - sbend_dxy[0] - itrans_dxy[0] - btrans_dxy[0] - otrans_dxy[0] - success = straight.length_range[0] <= straight_length < straight.length_range[1] - if success: - break - - # Straight didn't work, see if just the s-bend is enough - if sbend.ptype != straight.ptype: - # Need to use a different in-transition for sbend (vs straight) - in_ptype_pair = ('unk' if in_ptype is None else in_ptype, sbend.ptype) - in_transition = self.transitions.get(in_ptype_pair, None) - itrans_dxy = self._itransition2dxy(in_transition) - - jog_remaining = jog - itrans_dxy[1] - otrans_dxy[1] - if sbend.jog_range[0] <= jog_remaining < sbend.jog_range[1]: - sbend_dxy = self._sbend2dxy(sbend, jog_remaining) - success = numpy.isclose(length, sbend_dxy[0] + itrans_dxy[1] + otrans_dxy[1]) - if success: - b_transition = None - straight_length = 0 - break - if success: - break - - if not success: - try: - ccw0 = jog > 0 - p_test0, ldata_test0 = self.planL(length / 2, ccw0, in_ptype=in_ptype) - p_test1, ldata_test1 = self.planL(jog - p_test0.y, not ccw0, in_ptype=p_test0.ptype, out_ptype=out_ptype) - - dx = p_test1.x - length / 2 - p0, ldata0 = self.planL(length - dx, ccw0, in_ptype=in_ptype) - p1, ldata1 = self.planL(jog - p0.y, not ccw0, in_ptype=p0.ptype, out_ptype=out_ptype) - success = True - except BuildError as err: - l2_err: BuildError | None = err - else: - l2_err = None - raise NotImplementedError('TODO need to handle ldata below') - - if not success: - # Failed to break - raise BuildError( - f'Failed to find a valid s-bend configuration for {length=:,g}, {jog=:,g}, {in_ptype=}, {out_ptype=}' - ) from l2_err - - if out_transition is not None: - out_ptype_actual = out_transition.their_port.ptype - elif not numpy.isclose(jog_remaining, 0): - out_ptype_actual = sbend.ptype - elif not numpy.isclose(straight_length, 0): - out_ptype_actual = straight.ptype - else: - out_ptype_actual = self.default_out_ptype - - data = self.SData(straight_length, straight, kwargs, jog_remaining, sbend, in_transition, b_transition, out_transition) - out_port = Port((length, jog), rotation=pi, ptype=out_ptype_actual) - return out_port, data - - def _renderS( - self, - data: SData, - tree: ILibrary, - port_names: tuple[str, str], - gen_kwargs: dict[str, Any], - ) -> ILibrary: - """ - Render an L step into a preexisting tree - """ - pat = tree.top_pattern() - if data.in_transition: - pat.plug(data.in_transition.abstract, {port_names[1]: data.in_transition.their_port_name}) - if not numpy.isclose(data.straight_length, 0): - straight_pat_or_tree = data.straight.fn(data.straight_length, **(gen_kwargs | data.gen_kwargs)) - pmap = {port_names[1]: data.straight.in_port_name} - if isinstance(straight_pat_or_tree, Pattern): - straight_pat = straight_pat_or_tree - pat.plug(straight_pat, pmap, append=True) - else: - straight_tree = straight_pat_or_tree - top = straight_tree.top() - straight_tree.flatten(top, dangling_ok=True) - pat.plug(straight_tree[top], pmap, append=True) - if data.b_transition: - pat.plug(data.b_transition.abstract, {port_names[1]: data.b_transition.our_port_name}) - if not numpy.isclose(data.jog_remaining, 0): - sbend_pat_or_tree = data.sbend.fn(abs(data.jog_remaining), **(gen_kwargs | data.gen_kwargs)) - pmap = {port_names[1]: data.sbend.in_port_name} - if isinstance(sbend_pat_or_tree, Pattern): - pat.plug(sbend_pat_or_tree, pmap, append=True, mirrored=data.jog_remaining < 0) - else: - sbend_tree = sbend_pat_or_tree - top = sbend_tree.top() - sbend_tree.flatten(top, dangling_ok=True) - pat.plug(sbend_tree[top], pmap, append=True, mirrored=data.jog_remaining < 0) - if data.out_transition: - pat.plug(data.out_transition.abstract, {port_names[1]: data.out_transition.our_port_name}) - return tree - - def pathS( - self, - length: float, - jog: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> Library: - _out_port, data = self.planS( - length, - jog, - in_ptype = in_ptype, - out_ptype = out_ptype, - ) - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'pathS') - pat.add_port_pair(names=port_names, ptype='unk' if in_ptype is None else in_ptype) - self._renderS(data=data, tree=tree, port_names=port_names, gen_kwargs=kwargs) - return tree - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, - ) -> ILibrary: - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.add_port_pair(names=(port_names[0], port_names[1])) - - for step in batch: - assert step.tool == self - if step.opcode == 'L': - self._renderL(data=step.data, tree=tree, port_names=port_names, straight_kwargs=kwargs) - elif step.opcode == 'S': - self._renderS(data=step.data, tree=tree, port_names=port_names, gen_kwargs=kwargs) - return tree - - -@dataclass -class PathTool(Tool, metaclass=ABCMeta): - """ - A tool which draws `Path` geometry elements. - - If `planL` / `render` are used, the `Path` elements can cover >2 vertices; - with `path` only individual rectangles will be drawn. - """ - layer: layer_t - """ Layer to draw on """ - - width: float - """ `Path` width """ - - ptype: str = 'unk' - """ ptype for any ports in patterns generated by this tool """ - - #@dataclass(frozen=True, slots=True) - #class LData: - # dxy: NDArray[numpy.float64] - - #def __init__(self, layer: layer_t, width: float, ptype: str = 'unk') -> None: - # Tool.__init__(self) - # self.layer = layer - # self.width = width - # self.ptype: str - - def path( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, - out_ptype: str | None = None, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, # noqa: ARG002 (unused) - ) -> Library: - out_port, dxy = self.planL( - ccw, - length, - in_ptype=in_ptype, - out_ptype=out_ptype, - ) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.path(layer=self.layer, width=self.width, vertices=[(0, 0), (length, 0)]) - - if ccw is None: - out_rot = pi - elif bool(ccw): - out_rot = -pi / 2 - else: - out_rot = pi / 2 - - pat.ports = { - port_names[0]: Port((0, 0), rotation=0, ptype=self.ptype), - port_names[1]: Port(dxy, rotation=out_rot, ptype=self.ptype), - } - - return tree - - def planL( - self, - ccw: SupportsBool | None, - length: float, - *, - in_ptype: str | None = None, # noqa: ARG002 (unused) - out_ptype: str | None = None, - **kwargs, # noqa: ARG002 (unused) - ) -> tuple[Port, NDArray[numpy.float64]]: - # TODO check all the math for L-shaped bends - - if out_ptype and out_ptype != self.ptype: - raise BuildError(f'Requested {out_ptype=} does not match path ptype {self.ptype}') - - if ccw is not None: - bend_dxy = numpy.array([1, -1]) * self.width / 2 - bend_angle = pi / 2 - - if bool(ccw): - bend_dxy[1] *= -1 - bend_angle *= -1 - else: - bend_dxy = numpy.zeros(2) - bend_angle = pi - - straight_length = length - bend_dxy[0] - bend_run = bend_dxy[1] - - if straight_length < 0: - raise BuildError( - f'Asked to draw L-path with total length {length:,g}, shorter than required bend: {bend_dxy[0]:,g}' - ) - data = numpy.array((length, bend_run)) - out_port = Port(data, rotation=bend_angle, ptype=self.ptype) - return out_port, data - - def render( - self, - batch: Sequence[RenderStep], - *, - port_names: tuple[str, str] = ('A', 'B'), - **kwargs, # noqa: ARG002 (unused) - ) -> ILibrary: - - path_vertices = [batch[0].start_port.offset] - for step in batch: - assert step.tool == self - - port_rot = step.start_port.rotation - assert port_rot is not None - - if step.opcode == 'L': - length, bend_run = step.data - dxy = rotation_matrix_2d(port_rot + pi) @ (length, 0) - #path_vertices.append(step.start_port.offset) - path_vertices.append(step.start_port.offset + dxy) - else: - raise BuildError(f'Unrecognized opcode "{step.opcode}"') - - if (path_vertices[-1] != batch[-1].end_port.offset).any(): - # If the path ends in a bend, we need to add the final vertex - path_vertices.append(batch[-1].end_port.offset) - - tree, pat = Library.mktree(SINGLE_USE_PREFIX + 'path') - pat.path(layer=self.layer, width=self.width, vertices=path_vertices) - pat.ports = { - port_names[0]: batch[0].start_port.copy().rotate(pi), - port_names[1]: batch[-1].end_port.copy().rotate(pi), - } - return tree diff --git a/masque/builder/utils.py b/masque/builder/utils.py index 3109f46..72cce67 100644 --- a/masque/builder/utils.py +++ b/masque/builder/utils.py @@ -1,27 +1,26 @@ -from typing import SupportsFloat, cast, TYPE_CHECKING -from collections.abc import Mapping, Sequence +from typing import Dict, Tuple, List, Optional, Union, Any, cast, Sequence, TYPE_CHECKING from pprint import pformat import numpy from numpy import pi -from numpy.typing import ArrayLike, NDArray +from numpy.typing import ArrayLike -from ..utils import rotation_matrix_2d, SupportsBool +from ..utils import rotation_matrix_2d from ..error import BuildError if TYPE_CHECKING: - from ..ports import Port + from .devices import Port def ell( - ports: Mapping[str, 'Port'], - ccw: SupportsBool | None, + ports: Dict[str, 'Port'], + ccw: Optional[bool], bound_type: str, - bound: float | ArrayLike, + bound: Union[float, ArrayLike], *, - spacing: float | ArrayLike | None = None, - set_rotation: float | None = None, - ) -> dict[str, numpy.float64]: + spacing: Optional[Union[float, ArrayLike]] = None, + set_rotation: Optional[float] = None, + ) -> Dict[str, float]: """ Calculate extension for each port in order to build a 90-degree bend with the provided channel spacing: @@ -54,9 +53,9 @@ def ell( The distance between furthest out-port (B) and the innermost bend (D's bend). - 'max_extension' or 'emax': The total extension value for the closest-in port (C in the diagram). - - 'min_position', 'pmin', 'xmin', 'ymin': + - 'min_position' or 'pmin': The coordinate of the innermost bend (D's bend). - - 'max_position', 'pmax', 'xmax', 'ymax': + - 'max_position' or 'pmax': The coordinate of the outermost bend (A's bend). `bound` can also be a vector. If specifying an extension (e.g. 'min_extension', @@ -110,12 +109,6 @@ def ell( raise BuildError('set_rotation must be specified if no ports have rotations!') rotations = numpy.full_like(has_rotation, set_rotation, dtype=float) - is_horizontal = numpy.isclose(rotations[0] % pi, 0) - if bound_type in ('ymin', 'ymax') and is_horizontal: - raise BuildError(f'Asked for {bound_type} position but ports are pointing along the x-axis!') - if bound_type in ('xmin', 'xmax') and not is_horizontal: - raise BuildError(f'Asked for {bound_type} position but ports are pointing along the y-axis!') - direction = rotations[0] + pi # direction we want to travel in (+pi relative to port) rot_matrix = rotation_matrix_2d(-direction) @@ -123,8 +116,6 @@ def ell( orig_offsets = numpy.array([p.offset for p in ports.values()]) rot_offsets = (rot_matrix @ orig_offsets.T).T -# ordering_base = rot_offsets.T * [[1], [-1 if ccw else 1]] # could work, but this is actually a more complex routing problem -# y_order = numpy.lexsort(ordering_base) # (need to make sure we don't collide with the next input port @ same y) y_order = ((-1 if ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable') y_ind = numpy.empty_like(y_order, dtype=int) y_ind[y_order] = numpy.arange(y_ind.shape[0]) @@ -144,7 +135,6 @@ def ell( # D-----------| `d_to_align[3]` # d_to_align = x_start.max() - x_start # distance to travel to align all - offsets: NDArray[numpy.float64] if bound_type == 'min_past_furthest': # A------------------V `d_to_exit[0]` # B-----V `d_to_exit[1]` @@ -164,16 +154,15 @@ def ell( travel = d_to_align - (ch_offsets.max() - ch_offsets) offsets = travel - travel.min().clip(max=0) - rot_bound: SupportsFloat if bound_type in ('emin', 'min_extension', 'emax', 'max_extension', 'min_past_furthest',): if numpy.size(bound) == 2: - bound = cast('Sequence[float]', bound) + bound = cast(Sequence[float], bound) rot_bound = (rot_matrix @ ((bound[0], 0), (0, bound[1])))[0, :] else: - bound = cast('float', bound) + bound = cast(float, bound) rot_bound = numpy.array(bound) if rot_bound < 0: @@ -181,28 +170,27 @@ def ell( if bound_type in ('emin', 'min_extension', 'min_past_furthest'): offsets += rot_bound.max() - elif bound_type in ('emax', 'max_extension'): + elif bound_type in('emax', 'max_extension'): offsets += rot_bound.min() - offsets.max() else: if numpy.size(bound) == 2: - bound = cast('Sequence[float]', bound) + bound = cast(Sequence[float], bound) rot_bound = (rot_matrix @ bound)[0] else: - bound = cast('float', bound) + bound = cast(float, bound) neg = (direction + pi / 4) % (2 * pi) > pi rot_bound = -bound if neg else bound min_possible = x_start + offsets - if bound_type in ('pmax', 'max_position', 'xmax', 'ymax'): + if bound_type in ('pmax', 'max_position'): extension = rot_bound - min_possible.max() - elif bound_type in ('pmin', 'min_position', 'xmin', 'ymin'): + elif bound_type in ('pmin', 'min_position'): extension = rot_bound - min_possible.min() offsets += extension if extension < 0: - ext_floor = -numpy.floor(extension) - raise BuildError(f'Position is too close by at least {ext_floor}. Total extensions would be\n\t' - + '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets, strict=True))) + raise BuildError(f'Position is too close by at least {-numpy.floor(extension)}. Total extensions would be' + + '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets))) - result = dict(zip(ports.keys(), offsets, strict=True)) + result = dict(zip(ports.keys(), offsets)) return result diff --git a/masque/error.py b/masque/error.py index e475bb0..54290f9 100644 --- a/masque/error.py +++ b/masque/error.py @@ -1,10 +1,3 @@ -import traceback -import pathlib - - -MASQUE_DIR = str(pathlib.Path(__file__).parent) - - class MasqueError(Exception): """ Parent exception for all Masque-related Exceptions @@ -18,6 +11,13 @@ class PatternError(MasqueError): """ pass +class PatternLockedError(PatternError): + """ + Exception raised when trying to modify a locked pattern + """ + def __init__(self): + PatternError.__init__(self, 'Tried to modify a locked Pattern, subpattern, or shape') + class LibraryError(MasqueError): """ @@ -26,70 +26,22 @@ class LibraryError(MasqueError): pass +class DeviceLibraryError(MasqueError): + """ + Exception raised by DeviceLibrary classes + """ + pass + + +class DeviceError(MasqueError): + """ + Exception raised by Device and Port objects + """ + pass + + class BuildError(MasqueError): """ Exception raised by builder-related functions """ pass - - -class PortError(MasqueError): - """ - Exception raised by port-related functions - """ - pass - - -class OneShotError(MasqueError): - """ - Exception raised when a function decorated with `@oneshot` is called more than once - """ - def __init__(self, func_name: str) -> None: - Exception.__init__(self, f'Function "{func_name}" with @oneshot was called more than once') - - -def format_stacktrace( - stacklevel: int = 1, - *, - skip_file_prefixes: tuple[str, ...] = (MASQUE_DIR,), - low_file_prefixes: tuple[str, ...] = (''), - low_file_suffixes: tuple[str, ...] = ('IPython/utils/py3compat.py', 'concurrent/futures/process.py'), - ) -> str: - """ - Utility function for making nicer stack traces (e.g. excluding and similar) - - Args: - stacklevel: Number of frames to remove from near this function (default is to - show caller but not ourselves). Similar to `warnings.warn` and `logging.warning`. - skip_file_prefixes: Indicates frames to ignore after counting stack levels; similar - to `warnings.warn` *TODO check if this is actually the same effect re:stacklevel*. - Forces stacklevel to max(2, stacklevel). - Default is to exclude anything within `masque`. - low_file_prefixes: Indicates frames to ignore on the other (entry-point) end of the stack, - based on prefixes on their filenames. - low_file_suffixes: Indicates frames to ignore on the other (entry-point) end of the stack, - based on suffixes on their filenames. - - Returns: - Formatted trimmed stack trace - """ - if skip_file_prefixes: - stacklevel = max(2, stacklevel) - - stack = traceback.extract_stack() - - bad_inds = [ii + 1 for ii, frame in enumerate(stack) - if frame.filename.startswith(low_file_prefixes) or frame.filename.endswith(low_file_suffixes)] - first_ok = max([0] + bad_inds) - - last_ok = -stacklevel - 1 - while last_ok >= -len(stack) and stack[last_ok].filename.startswith(skip_file_prefixes): - last_ok -= 1 - - if selected := stack[first_ok:last_ok + 1]: - pass - elif selected := stack[:-stacklevel]: - pass # noqa: SIM114 # separate elif for clarity - else: - selected = stack - return ''.join(traceback.format_list(selected)) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 0f6dd32..4a6b9e3 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -1,51 +1,45 @@ """ DXF file format readers and writers - -Notes: - * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01) - * ezdxf sets creation time, write time, $VERSIONGUID, and $FINGERPRINTGUID - to unique values, so byte-for-byte reproducibility is not achievable for now """ -from typing import Any, cast, TextIO, IO -from collections.abc import Mapping, Callable +from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable +import re import io +import base64 +import struct import logging import pathlib import gzip -import numpy -import ezdxf -from ezdxf.enums import TextEntityAlignment -from ezdxf.entities import LWPolyline, Polyline, Text, Insert +import numpy # type: ignore +import ezdxf # type: ignore -from .utils import is_gzipped, tmpfile -from .. import Pattern, Ref, PatternError, Label -from ..library import ILibraryView, LibraryView, Library -from ..shapes import Shape, Polygon, Path +from .. import Pattern, SubPattern, PatternError, Label, Shape +from ..shapes import Polygon, Path from ..repetition import Grid -from ..utils import rotation_matrix_2d, layer_t, normalize_mirror +from ..utils import rotation_matrix_2d, layer_t logger = logging.getLogger(__name__) -logger.warning('DXF support is experimental!') +logger.warning('DXF support is experimental and only slightly tested!') DEFAULT_LAYER = 'DEFAULT' def write( - library: Mapping[str, Pattern], # TODO could allow library=None for flat DXF - top_name: str, - stream: TextIO, + pattern: Pattern, + stream: io.TextIOBase, *, - dxf_version: str = 'AC1024', + modify_originals: bool = False, + dxf_version='AC1024', + disambiguate_func: Callable[[Iterable[Pattern]], None] = None, ) -> None: """ Write a `Pattern` to a DXF file, by first calling `.polygonize()` to change the shapes into polygons, and then writing patterns as DXF `Block`s, polygons as `LWPolyline`s, - and refs as `Insert`s. + and subpatterns as `Insert`s. The top level pattern's name is not written to the DXF file. Nested patterns keep their names. @@ -55,61 +49,60 @@ def write( tuple: (1, 2) -> '1.2' str: '1.2' -> '1.2' (no change) - DXF does not support shape repetition (only block repeptition). Please call - library.wrap_repeated_shapes() before writing to file. + It is often a good idea to run `pattern.subpatternize()` prior to calling this function, + especially if calling `.polygonize()` will result in very many vertices. - Other functions you may want to call: - - `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names - - `library.dangling_refs()` to check for references to missing patterns - - `pattern.polygonize()` for any patterns with shapes other - than `masque.shapes.Polygon` or `masque.shapes.Path` + If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` + prior to calling this function. Only `Grid` repetition objects with manhattan basis vectors are preserved as arrays. Since DXF rotations apply to basis vectors while `masque`'s rotations do not, the basis vectors of an array with rotated instances must be manhattan _after_ having a compensating rotation applied. Args: - library: A {name: Pattern} mapping of patterns. Only `top_name` and patterns referenced - by it are written. - top_name: Name of the top-level pattern to write. + patterns: A Pattern or list of patterns to write to the stream. stream: Stream object to write to. + modify_original: If `True`, the original pattern is modified as part of the writing + process. Otherwise, a copy is made and `deepunlock()`-ed. + Default `False`. + disambiguate_func: Function which takes a list of patterns and alters them + to make their names valid and unique. Default is `disambiguate_pattern_names`. + WARNING: No additional error checking is performed on the results. """ #TODO consider supporting DXF arcs? - if not isinstance(library, ILibraryView): - if isinstance(library, dict): - library = LibraryView(library) - else: - library = LibraryView(dict(library)) + if disambiguate_func is None: + disambiguate_func = lambda pats: disambiguate_pattern_names(pats) + assert(disambiguate_func is not None) - pattern = library[top_name] - subtree = library.subtree(top_name) + if not modify_originals: + pattern = pattern.deepcopy().deepunlock() + + # Get a dict of id(pattern) -> pattern + patterns_by_id = pattern.referenced_patterns_by_id() + disambiguate_func(patterns_by_id.values()) # Create library lib = ezdxf.new(dxf_version, setup=True) msp = lib.modelspace() _shapes_to_elements(msp, pattern.shapes) _labels_to_texts(msp, pattern.labels) - _mrefs_to_drefs(msp, pattern.refs) + _subpatterns_to_refs(msp, pattern.subpatterns) # Now create a block for each referenced pattern, and add in any shapes - for name, pat in subtree.items(): - assert pat is not None - if name == top_name: - continue - - block = lib.blocks.new(name=name) + for pat in patterns_by_id.values(): + assert(pat is not None) + block = lib.blocks.new(name=pat.name) _shapes_to_elements(block, pat.shapes) _labels_to_texts(block, pat.labels) - _mrefs_to_drefs(block, pat.refs) + _subpatterns_to_refs(block, pat.subpatterns) lib.write(stream) def writefile( - library: Mapping[str, Pattern], - top_name: str, - filename: str | pathlib.Path, + pattern: Pattern, + filename: Union[str, pathlib.Path], *args, **kwargs, ) -> None: @@ -119,42 +112,30 @@ def writefile( Will automatically compress the file if it has a .gz suffix. Args: - library: A {name: Pattern} mapping of patterns. Only `top_name` and patterns referenced - by it are written. - top_name: Name of the top-level pattern to write. + pattern: `Pattern` to save filename: Filename to save to. *args: passed to `dxf.write` **kwargs: passed to `dxf.write` """ path = pathlib.Path(filename) + if path.suffix == '.gz': + open_func: Callable = gzip.open + else: + open_func = open - gz_stream: IO[bytes] - with tmpfile(path) as base_stream: - streams: tuple[Any, ...] = (base_stream,) - if path.suffix == '.gz': - gz_stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb')) - streams = (gz_stream,) + streams - else: - gz_stream = base_stream - stream = io.TextIOWrapper(gz_stream) # type: ignore - streams = (stream,) + streams - - try: - write(library, top_name, stream, *args, **kwargs) - finally: - for ss in streams: - ss.close() + with open_func(path, mode='wt') as stream: + write(pattern, stream, *args, **kwargs) def readfile( - filename: str | pathlib.Path, + filename: Union[str, pathlib.Path], *args, **kwargs, - ) -> tuple[Library, dict[str, Any]]: + ) -> Tuple[Pattern, Dict[str, Any]]: """ Wrapper for `dxf.read()` that takes a filename or path instead of a stream. - Will automatically decompress gzipped files. + Will automatically decompress files with a .gz suffix. Args: filename: Filename to save to. @@ -162,7 +143,7 @@ def readfile( **kwargs: passed to `dxf.read` """ path = pathlib.Path(filename) - if is_gzipped(path): + if path.suffix == '.gz': open_func: Callable = gzip.open else: open_func = open @@ -173,17 +154,21 @@ def readfile( def read( - stream: TextIO, - ) -> tuple[Library, dict[str, Any]]: + stream: io.TextIOBase, + clean_vertices: bool = True, + ) -> Tuple[Pattern, Dict[str, Any]]: """ Read a dxf file and translate it into a dict of `Pattern` objects. DXF `Block`s are translated into `Pattern` objects; `LWPolyline`s are translated into polygons, and `Insert`s - are translated into `Ref` objects. + are translated into `SubPattern` objects. If an object has no layer it is set to this module's `DEFAULT_LAYER` ("DEFAULT"). Args: stream: Stream to read from. + clean_vertices: If `True`, remove any redundant vertices when loading polygons. + The cleaning process removes any polygons with zero area or <3 vertices. + Default `True`. Returns: - Top level pattern @@ -191,183 +176,177 @@ def read( lib = ezdxf.read(stream) msp = lib.modelspace() - top_name, top_pat = _read_block(msp) - mlib = Library({top_name: top_pat}) - for bb in lib.blocks: - if bb.name == '*Model_Space': - continue - name, pat = _read_block(bb) - mlib[name] = pat + pat = _read_block(msp, clean_vertices) + patterns = [pat] + [_read_block(bb, clean_vertices) for bb in lib.blocks if bb.name != '*Model_Space'] - library_info = dict( - layers=[ll.dxfattribs() for ll in lib.layers], - ) + # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries + # according to the subpattern.identifier (which is deleted after use). + patterns_dict = dict(((p.name, p) for p in patterns)) + for p in patterns_dict.values(): + for sp in p.subpatterns: + sp.pattern = patterns_dict[sp.identifier[0]] + del sp.identifier - return mlib, library_info + library_info = { + 'layers': [ll.dxfattribs() for ll in lib.layers] + } + + return pat, library_info -def _read_block(block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace) -> tuple[str, Pattern]: - name = block.name - pat = Pattern() +def _read_block(block, clean_vertices: bool) -> Pattern: + pat = Pattern(block.name) for element in block: - if isinstance(element, LWPolyline | Polyline): - if isinstance(element, LWPolyline): - points = numpy.asarray(element.get_points()) - elif isinstance(element, Polyline): - points = numpy.asarray([pp.xyz for pp in element.points()]) + eltype = element.dxftype() + if eltype in ('POLYLINE', 'LWPOLYLINE'): + if eltype == 'LWPOLYLINE': + points = numpy.array(tuple(element.lwpoints)) + else: + points = numpy.array(tuple(element.points())) attr = element.dxfattribs() layer = attr.get('layer', DEFAULT_LAYER) if points.shape[1] == 2: raise PatternError('Invalid or unimplemented polygon?') - - if points.shape[1] > 2: + #shape = Polygon(layer=layer) + elif points.shape[1] > 2: if (points[0, 2] != points[:, 2]).any(): raise PatternError('PolyLine has non-constant width (not yet representable in masque!)') - if points.shape[1] == 4 and (points[:, 3] != 0).any(): + elif points.shape[1] == 4 and (points[:, 3] != 0).any(): raise PatternError('LWPolyLine has bulge (not yet representable in masque!)') width = points[0, 2] if width == 0: width = attr.get('const_width', 0) - shape: Path | Polygon + shape: Union[Path, Polygon] if width == 0 and len(points) > 2 and numpy.array_equal(points[0], points[-1]): - shape = Polygon(vertices=points[:-1, :2]) + shape = Polygon(layer=layer, vertices=points[:-1, :2]) else: - shape = Path(width=width, vertices=points[:, :2]) + shape = Path(layer=layer, width=width, vertices=points[:, :2]) - pat.shapes[layer].append(shape) + if clean_vertices: + try: + shape.clean_vertices() + except PatternError: + continue - elif isinstance(element, Text): - args = dict( - offset=numpy.asarray(element.get_placement()[1])[:2], - layer=element.dxfattribs().get('layer', DEFAULT_LAYER), - ) + pat.shapes.append(shape) + + elif eltype in ('TEXT',): + args = {'offset': numpy.array(element.get_pos()[1])[:2], + 'layer': element.dxfattribs().get('layer', DEFAULT_LAYER), + } string = element.dxfattribs().get('text', '') # height = element.dxfattribs().get('height', 0) # if height != 0: # logger.warning('Interpreting DXF TEXT as a label despite nonzero height. ' # 'This could be changed in the future by setting a font path in the masque DXF code.') - pat.label(string=string, **args) + pat.labels.append(Label(string=string, **args)) # else: -# pat.shapes[args['layer']].append(Text(string=string, height=height, font_path=????)) - elif isinstance(element, Insert): +# pat.shapes.append(Text(string=string, height=height, font_path=????)) + elif eltype in ('INSERT',): attr = element.dxfattribs() xscale = attr.get('xscale', 1) yscale = attr.get('yscale', 1) if abs(xscale) != abs(yscale): logger.warning('Masque does not support per-axis scaling; using x-scaling only!') scale = abs(xscale) - mirrored, extra_angle = normalize_mirror((yscale < 0, xscale < 0)) - rotation = numpy.deg2rad(attr.get('rotation', 0)) + extra_angle + mirrored = (yscale < 0, xscale < 0) + rotation = numpy.deg2rad(attr.get('rotation', 0)) - offset = numpy.asarray(attr.get('insert', (0, 0, 0)))[:2] + offset = numpy.array(attr.get('insert', (0, 0, 0)))[:2] - args = dict( - target=attr.get('name', None), - offset=offset, - scale=scale, - mirrored=mirrored, - rotation=rotation, - ) + args = { + 'offset': offset, + 'scale': scale, + 'mirrored': mirrored, + 'rotation': rotation, + 'pattern': None, + 'identifier': (attr.get('name', None),), + } if 'column_count' in attr: - args['repetition'] = Grid( - a_vector=(attr['column_spacing'], 0), - b_vector=(0, attr['row_spacing']), - a_count=attr['column_count'], - b_count=attr['row_count'], - ) - pat.ref(**args) + args['repetition'] = Grid(a_vector=(attr['column_spacing'], 0), + b_vector=(0, attr['row_spacing']), + a_count=attr['column_count'], + b_count=attr['row_count']) + pat.subpatterns.append(SubPattern(**args)) else: logger.warning(f'Ignoring DXF element {element.dxftype()} (not implemented).') - return name, pat + return pat -def _mrefs_to_drefs( - block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, - refs: dict[str | None, list[Ref]], +def _subpatterns_to_refs( + block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], + subpatterns: List[SubPattern], ) -> None: - def mk_blockref(encoded_name: str, ref: Ref) -> None: - rotation = numpy.rad2deg(ref.rotation) % 360 - attribs = dict( - xscale=ref.scale, - yscale=ref.scale * (-1 if ref.mirrored else 1), - rotation=rotation, - ) + for subpat in subpatterns: + if subpat.pattern is None: + continue + encoded_name = subpat.pattern.name - rep = ref.repetition + rotation = (subpat.rotation * 180 / numpy.pi) % 360 + attribs = { + 'xscale': subpat.scale * (-1 if subpat.mirrored[1] else 1), + 'yscale': subpat.scale * (-1 if subpat.mirrored[0] else 1), + 'rotation': rotation, + } + + rep = subpat.repetition if rep is None: - block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs) + block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) elif isinstance(rep, Grid): a = rep.a_vector b = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) - rotated_a = rotation_matrix_2d(-ref.rotation) @ a - rotated_b = rotation_matrix_2d(-ref.rotation) @ b + rotated_a = rotation_matrix_2d(-subpat.rotation) @ a + rotated_b = rotation_matrix_2d(-subpat.rotation) @ b if rotated_a[1] == 0 and rotated_b[0] == 0: attribs['column_count'] = rep.a_count attribs['row_count'] = rep.b_count attribs['column_spacing'] = rotated_a[0] attribs['row_spacing'] = rotated_b[1] - block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs) + block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) elif rotated_a[0] == 0 and rotated_b[1] == 0: attribs['column_count'] = rep.b_count attribs['row_count'] = rep.a_count attribs['column_spacing'] = rotated_b[0] attribs['row_spacing'] = rotated_a[1] - block.add_blockref(encoded_name, ref.offset, dxfattribs=attribs) + block.add_blockref(encoded_name, subpat.offset, dxfattribs=attribs) else: #NOTE: We could still do non-manhattan (but still orthogonal) grids by getting # creative with counter-rotated nested patterns, but probably not worth it. # Instead, just break appart the grid into individual elements: for dd in rep.displacements: - block.add_blockref(encoded_name, ref.offset + dd, dxfattribs=attribs) + block.add_blockref(encoded_name, subpat.offset + dd, dxfattribs=attribs) else: for dd in rep.displacements: - block.add_blockref(encoded_name, ref.offset + dd, dxfattribs=attribs) - - for target, rseq in refs.items(): - if target is None: - continue - for ref in rseq: - mk_blockref(target, ref) + block.add_blockref(encoded_name, subpat.offset + dd, dxfattribs=attribs) def _shapes_to_elements( - block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, - shapes: dict[layer_t, list[Shape]], + block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], + shapes: List[Shape], + polygonize_paths: bool = False, ) -> None: # Add `LWPolyline`s for each shape. # Could set do paths with width setting, but need to consider endcaps. - # TODO: can DXF do paths? - for layer, sseq in shapes.items(): - attribs = dict(layer=_mlayer2dxf(layer)) - for shape in sseq: - if shape.repetition is not None: - raise PatternError( - 'Shape repetitions are not supported by DXF.' - ' Please call library.wrap_repeated_shapes() before writing to file.' - ) - - for polygon in shape.to_polygons(): - xy_open = polygon.vertices - xy_closed = numpy.vstack((xy_open, xy_open[0, :])) - block.add_lwpolyline(xy_closed, dxfattribs=attribs) + for shape in shapes: + attribs = {'layer': _mlayer2dxf(shape.layer)} + for polygon in shape.to_polygons(): + xy_open = polygon.vertices + polygon.offset + xy_closed = numpy.vstack((xy_open, xy_open[0, :])) + block.add_lwpolyline(xy_closed, dxfattribs=attribs) def _labels_to_texts( - block: ezdxf.layouts.BlockLayout | ezdxf.layouts.Modelspace, - labels: dict[layer_t, list[Label]], + block: Union[ezdxf.layouts.BlockLayout, ezdxf.layouts.Modelspace], + labels: List[Label], ) -> None: - for layer, lseq in labels.items(): - attribs = dict(layer=_mlayer2dxf(layer)) - for label in lseq: - xy = label.offset - block.add_text( - label.string, - dxfattribs=attribs - ).set_placement(xy, align=TextEntityAlignment.BOTTOM_LEFT) + for label in labels: + attribs = {'layer': _mlayer2dxf(label.layer)} + xy = label.offset + block.add_text(label.string, dxfattribs=attribs).set_pos(xy, align='BOTTOM_LEFT') def _mlayer2dxf(layer: layer_t) -> str: @@ -376,5 +355,42 @@ def _mlayer2dxf(layer: layer_t) -> str: if isinstance(layer, int): return str(layer) if isinstance(layer, tuple): - return f'{layer[0]:d}.{layer[1]:d}' + return f'{layer[0]}.{layer[1]}' raise PatternError(f'Unknown layer type: {layer} ({type(layer)})') + + +def disambiguate_pattern_names( + patterns: Iterable[Pattern], + max_name_length: int = 32, + suffix_length: int = 6, + dup_warn_filter: Callable[[str], bool] = None, # If returns False, don't warn about this name + ) -> None: + used_names = [] + for pat in patterns: + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', pat.name) + + i = 0 + suffixed_name = sanitized_name + while suffixed_name in used_names or suffixed_name == '': + suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII') + + suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A') + i += 1 + + if sanitized_name == '': + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') + elif suffixed_name != sanitized_name: + if dup_warn_filter is None or dup_warn_filter(pat.name): + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') + + if len(suffixed_name) == 0: + # Should never happen since zero-length names are replaced + raise PatternError(f'Zero-length name after sanitize,\n originally "{pat.name}"') + if len(suffixed_name) > max_name_length: + raise PatternError(f'Pattern name "{suffixed_name!r}" length > {max_name_length} after encode,\n' + + f' originally "{pat.name}"') + + pat.name = suffixed_name + used_names.append(suffixed_name) + diff --git a/masque/file/gdsii.py b/masque/file/gdsii.py index 6972cfa..6bd4d1a 100644 --- a/masque/file/gdsii.py +++ b/masque/file/gdsii.py @@ -16,32 +16,31 @@ Notes: * PLEX is not supported * ELFLAGS are not supported * GDS does not support library- or structure-level annotations - * GDS creation/modification/access times are set to 1900-01-01 for reproducibility. - * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01) + * Creation/modification/access times are set to 1900-01-01 for reproducibility. """ -from typing import IO, cast, Any -from collections.abc import Iterable, Mapping, Callable -from types import MappingProxyType +from typing import List, Any, Dict, Tuple, Callable, Union, Iterable, Optional +from typing import Sequence, BinaryIO +import re import io import mmap +import copy +import base64 +import struct import logging import pathlib import gzip -import string -from pprint import pformat import numpy -from numpy.typing import ArrayLike, NDArray +from numpy.typing import NDArray, ArrayLike import klamath from klamath import records -from .utils import is_gzipped, tmpfile -from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape +from .utils import is_gzipped +from .. import Pattern, SubPattern, PatternError, Label, Shape from ..shapes import Polygon, Path from ..repetition import Grid -from ..utils import layer_t, annotations_t -from ..library import LazyLibrary, Library, ILibrary, ILibraryView - +from ..utils import layer_t, normalize_mirror, annotations_t +from ..library import Library logger = logging.getLogger(__name__) @@ -53,24 +52,21 @@ path_cap_map = { 4: Path.Cap.SquareCustom, } -RO_EMPTY_DICT: Mapping[int, bytes] = MappingProxyType({}) - - -def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]: - return numpy.rint(val).astype(numpy.int32) - def write( - library: Mapping[str, Pattern], - stream: IO[bytes], + patterns: Union[Pattern, Sequence[Pattern]], + stream: BinaryIO, meters_per_unit: float, logical_units_per_unit: float = 1, library_name: str = 'masque-klamath', + *, + modify_originals: bool = False, + disambiguate_func: Callable[[Iterable[Pattern]], None] = None, ) -> None: """ - Convert a library to a GDSII stream, mapping data as follows: + Convert a `Pattern` or list of patterns to a GDSII stream, and then mapping data as follows: Pattern -> GDSII structure - Ref -> GDSII SREF or AREF + SubPattern -> GDSII SREF or AREF Path -> GSDII path Shape (other than path) -> GDSII boundary/ies Label -> GDSII text @@ -82,17 +78,14 @@ def write( datatype is chosen to be `shape.layer[1]` if available, otherwise `0` - GDS does not support shape repetition (only cell repeptition). Please call - `library.wrap_repeated_shapes()` before writing to file. + It is often a good idea to run `pattern.subpatternize()` prior to calling this function, + especially if calling `.polygonize()` will result in very many vertices. - Other functions you may want to call: - - `masque.file.gdsii.check_valid_names(library.keys())` to check for invalid names - - `library.dangling_refs()` to check for references to missing patterns - - `pattern.polygonize()` for any patterns with shapes other - than `masque.shapes.Polygon` or `masque.shapes.Path` + If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` + prior to calling this function. Args: - library: A {name: Pattern} mapping of patterns to write. + patterns: A Pattern or list of patterns to convert. meters_per_unit: Written into the GDSII file, meters per (database) length unit. All distances are assumed to be an integer multiple of this unit, and are stored as such. logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a @@ -100,35 +93,54 @@ def write( Default `1`. library_name: Library name written into the GDSII file. Default 'masque-klamath'. + modify_originals: If `True`, the original pattern is modified as part of the writing + process. Otherwise, a copy is made and `deepunlock()`-ed. + Default `False`. + disambiguate_func: Function which takes a list of patterns and alters them + to make their names valid and unique. Default is `disambiguate_pattern_names`, which + attempts to adhere to the GDSII standard as well as possible. + WARNING: No additional error checking is performed on the results. """ - if not isinstance(library, ILibrary): - if isinstance(library, dict): - library = Library(library) - else: - library = Library(dict(library)) + if isinstance(patterns, Pattern): + patterns = [patterns] + + if disambiguate_func is None: + disambiguate_func = disambiguate_pattern_names # type: ignore + assert(disambiguate_func is not None) # placate mypy + + if not modify_originals: + patterns = [p.deepunlock() for p in copy.deepcopy(patterns)] + + patterns = [p.wrap_repeated_shapes() for p in patterns] # Create library - header = klamath.library.FileHeader( - name=library_name.encode('ASCII'), - user_units_per_db_unit=logical_units_per_unit, - meters_per_db_unit=meters_per_unit, - ) + header = klamath.library.FileHeader(name=library_name.encode('ASCII'), + user_units_per_db_unit=logical_units_per_unit, + meters_per_db_unit=meters_per_unit) header.write(stream) + # Get a dict of id(pattern) -> pattern + patterns_by_id = {id(pattern): pattern for pattern in patterns} + for pattern in patterns: + for i, p in pattern.referenced_patterns_by_id().items(): + patterns_by_id[i] = p + + disambiguate_func(patterns_by_id.values()) + # Now create a structure for each pattern, and add in any Boundary and SREF elements - for name, pat in library.items(): - elements: list[klamath.elements.Element] = [] + for pat in patterns_by_id.values(): + elements: List[klamath.elements.Element] = [] elements += _shapes_to_elements(pat.shapes) elements += _labels_to_texts(pat.labels) - elements += _mrefs_to_grefs(pat.refs) + elements += _subpatterns_to_refs(pat.subpatterns) - klamath.library.write_struct(stream, name=name.encode('ASCII'), elements=elements) + klamath.library.write_struct(stream, name=pat.name.encode('ASCII'), elements=elements) records.ENDLIB.write(stream, None) def writefile( - library: Mapping[str, Pattern], - filename: str | pathlib.Path, + patterns: Union[Sequence[Pattern], Pattern], + filename: Union[str, pathlib.Path], *args, **kwargs, ) -> None: @@ -138,33 +150,26 @@ def writefile( Will automatically compress the file if it has a .gz suffix. Args: - library: {name: Pattern} pairs to save. + patterns: `Pattern` or list of patterns to save filename: Filename to save to. *args: passed to `write()` **kwargs: passed to `write()` """ path = pathlib.Path(filename) + if path.suffix == '.gz': + open_func: Callable = gzip.open + else: + open_func = open - with tmpfile(path) as base_stream: - streams: tuple[Any, ...] = (base_stream,) - if path.suffix == '.gz': - stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb', compresslevel=6)) - streams = (stream,) + streams - else: - stream = base_stream - - try: - write(library, stream, *args, **kwargs) - finally: - for ss in streams: - ss.close() + with io.BufferedWriter(open_func(path, mode='wb')) as stream: + write(patterns, stream, *args, **kwargs) def readfile( - filename: str | pathlib.Path, + filename: Union[str, pathlib.Path], *args, **kwargs, - ) -> tuple[Library, dict[str, Any]]: + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: """ Wrapper for `read()` that takes a filename or path instead of a stream. @@ -181,20 +186,19 @@ def readfile( else: open_func = open - with open_func(path, mode='rb') as stream: + with io.BufferedReader(open_func(path, mode='rb')) as stream: results = read(stream, *args, **kwargs) return results def read( - stream: IO[bytes], + stream: BinaryIO, raw_mode: bool = True, - ) -> tuple[Library, dict[str, Any]]: + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: """ - # TODO check GDSII file for cycles! Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs - are translated into Ref objects. + are translated into SubPattern objects. Additional library info is returned in a dict, containing: 'name': name of the library @@ -207,23 +211,31 @@ def read( raw_mode: If True, constructs shapes in raw mode, bypassing most data validation, Default True. Returns: - - dict of pattern_name:Patterns generated from GDSII structures - - dict of GDSII library info + - Dict of pattern_name:Patterns generated from GDSII structures + - Dict of GDSII library info """ library_info = _read_header(stream) - mlib = Library() + patterns = [] found_struct = records.BGNSTR.skip_past(stream) while found_struct: name = records.STRNAME.skip_and_read(stream) - pat = read_elements(stream, raw_mode=raw_mode) - mlib[name.decode('ASCII')] = pat + pat = read_elements(stream, name=name.decode('ASCII'), raw_mode=raw_mode) + patterns.append(pat) found_struct = records.BGNSTR.skip_past(stream) - return mlib, library_info + # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries + # according to the subpattern.identifier (which is deleted after use). + patterns_dict = dict(((p.name, p) for p in patterns)) + for p in patterns_dict.values(): + for sp in p.subpatterns: + sp.pattern = patterns_dict[sp.identifier[0]] + del sp.identifier + + return patterns_dict, library_info -def _read_header(stream: IO[bytes]) -> dict[str, Any]: +def _read_header(stream: BinaryIO) -> Dict[str, Any]: """ Read the file header and create the library_info dict. """ @@ -237,7 +249,8 @@ def _read_header(stream: IO[bytes]) -> dict[str, Any]: def read_elements( - stream: IO[bytes], + stream: BinaryIO, + name: str, raw_mode: bool = True, ) -> Pattern: """ @@ -252,30 +265,28 @@ def read_elements( Returns: A pattern containing the elements that were read. """ - pat = Pattern() + pat = Pattern(name) elements = klamath.library.read_elements(stream) for element in elements: if isinstance(element, klamath.elements.Boundary): - layer, poly = _boundary_to_polygon(element, raw_mode) - pat.shapes[layer].append(poly) + poly = _boundary_to_polygon(element, raw_mode) + pat.shapes.append(poly) elif isinstance(element, klamath.elements.Path): - layer, path = _gpath_to_mpath(element, raw_mode) - pat.shapes[layer].append(path) + path = _gpath_to_mpath(element, raw_mode) + pat.shapes.append(path) elif isinstance(element, klamath.elements.Text): - pat.label( - layer=element.layer, - offset=element.xy.astype(float), - string=element.string.decode('ASCII'), - annotations=_properties_to_annotations(element.properties), - ) + label = Label(offset=element.xy.astype(float), + layer=element.layer, + string=element.string.decode('ASCII'), + annotations=_properties_to_annotations(element.properties)) + pat.labels.append(label) elif isinstance(element, klamath.elements.Reference): - target, ref = _gref_to_mref(element) - pat.refs[target].append(ref) + pat.subpatterns.append(_ref_to_subpat(element)) return pat -def _mlayer2gds(mlayer: layer_t) -> tuple[int, int]: +def _mlayer2gds(mlayer: layer_t) -> Tuple[int, int]: """ Helper to turn a layer tuple-or-int into a layer and datatype""" if isinstance(mlayer, int): layer = mlayer @@ -291,9 +302,10 @@ def _mlayer2gds(mlayer: layer_t) -> tuple[int, int]: return layer, data_type -def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, Ref]: +def _ref_to_subpat(ref: klamath.library.Reference) -> SubPattern: """ - Helper function to create a Ref from an SREF or AREF. Sets ref.target to struct_name. + Helper function to create a SubPattern from an SREF or AREF. Sets subpat.pattern to None + and sets the instance .identifier to (struct_name,). """ xy = ref.xy.astype(float) offset = xy[0] @@ -305,121 +317,110 @@ def _gref_to_mref(ref: klamath.library.Reference) -> tuple[str, Ref]: repetition = Grid(a_vector=a_vector, b_vector=b_vector, a_count=a_count, b_count=b_count) - target = ref.struct_name.decode('ASCII') - mref = Ref( - offset=offset, - rotation=numpy.deg2rad(ref.angle_deg), - scale=ref.mag, - mirrored=ref.invert_y, - annotations=_properties_to_annotations(ref.properties), - repetition=repetition, - ) - return target, mref + subpat = SubPattern(pattern=None, + offset=offset, + rotation=numpy.deg2rad(ref.angle_deg), + scale=ref.mag, + mirrored=(ref.invert_y, False), + annotations=_properties_to_annotations(ref.properties), + repetition=repetition) + subpat.identifier = (ref.struct_name.decode('ASCII'),) + return subpat -def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> tuple[layer_t, Path]: +def _gpath_to_mpath(gpath: klamath.library.Path, raw_mode: bool) -> Path: if gpath.path_type in path_cap_map: cap = path_cap_map[gpath.path_type] else: raise PatternError(f'Unrecognized path type: {gpath.path_type}') - mpath = Path( - vertices=gpath.xy.astype(float), - width=gpath.width, - cap=cap, - offset=numpy.zeros(2), - annotations=_properties_to_annotations(gpath.properties), - raw=raw_mode, - ) + mpath = Path(vertices=gpath.xy.astype(float), + layer=gpath.layer, + width=gpath.width, + cap=cap, + offset=numpy.zeros(2), + annotations=_properties_to_annotations(gpath.properties), + raw=raw_mode, + ) if cap == Path.Cap.SquareCustom: mpath.cap_extensions = gpath.extension - return gpath.layer, mpath + return mpath -def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> tuple[layer_t, Polygon]: - return boundary.layer, Polygon( - vertices=boundary.xy[:-1].astype(float), - offset=numpy.zeros(2), - annotations=_properties_to_annotations(boundary.properties), - raw=raw_mode, - ) +def _boundary_to_polygon(boundary: klamath.library.Boundary, raw_mode: bool) -> Polygon: + return Polygon(vertices=boundary.xy[:-1].astype(float), + layer=boundary.layer, + offset=numpy.zeros(2), + annotations=_properties_to_annotations(boundary.properties), + raw=raw_mode, + ) -def _mrefs_to_grefs(refs: dict[str | None, list[Ref]]) -> list[klamath.library.Reference]: - grefs = [] - for target, rseq in refs.items(): - if target is None: +def _subpatterns_to_refs(subpatterns: List[SubPattern]) -> List[klamath.library.Reference]: + refs = [] + for subpat in subpatterns: + if subpat.pattern is None: continue - encoded_name = target.encode('ASCII') - for ref in rseq: - # Note: GDS also mirrors first and rotates second - rep = ref.repetition - angle_deg = numpy.rad2deg(ref.rotation) % 360 - properties = _annotations_to_properties(ref.annotations, 512) + encoded_name = subpat.pattern.name.encode('ASCII') - if isinstance(rep, Grid): - b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) - b_count = rep.b_count if rep.b_count is not None else 1 - xy = numpy.asarray(ref.offset) + numpy.array([ - [0.0, 0.0], - rep.a_vector * rep.a_count, - b_vector * b_count, - ]) - aref = klamath.library.Reference( - struct_name=encoded_name, - xy=rint_cast(xy), - colrow=(numpy.rint(rep.a_count), numpy.rint(rep.b_count)), - angle_deg=angle_deg, - invert_y=ref.mirrored, - mag=ref.scale, - properties=properties, - ) - grefs.append(aref) - elif rep is None: - sref = klamath.library.Reference( - struct_name=encoded_name, - xy=rint_cast([ref.offset]), - colrow=None, - angle_deg=angle_deg, - invert_y=ref.mirrored, - mag=ref.scale, - properties=properties, - ) - grefs.append(sref) - else: - new_srefs = [ - klamath.library.Reference( - struct_name=encoded_name, - xy=rint_cast([ref.offset + dd]), - colrow=None, - angle_deg=angle_deg, - invert_y=ref.mirrored, - mag=ref.scale, - properties=properties, - ) - for dd in rep.displacements] - grefs += new_srefs - return grefs + # Note: GDS mirrors first and rotates second + mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored) + rep = subpat.repetition + angle_deg = numpy.rad2deg(subpat.rotation + extra_angle) % 360 + properties = _annotations_to_properties(subpat.annotations, 512) + + if isinstance(rep, Grid): + b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) + b_count = rep.b_count if rep.b_count is not None else 1 + xy: NDArray[numpy.float64] = numpy.array(subpat.offset) + [ + [0, 0], + rep.a_vector * rep.a_count, + b_vector * b_count, + ] + aref = klamath.library.Reference(struct_name=encoded_name, + xy=numpy.round(xy).astype(int), + colrow=(numpy.round(rep.a_count), numpy.round(rep.b_count)), + angle_deg=angle_deg, + invert_y=mirror_across_x, + mag=subpat.scale, + properties=properties) + refs.append(aref) + elif rep is None: + ref = klamath.library.Reference(struct_name=encoded_name, + xy=numpy.round([subpat.offset]).astype(int), + colrow=None, + angle_deg=angle_deg, + invert_y=mirror_across_x, + mag=subpat.scale, + properties=properties) + refs.append(ref) + else: + new_srefs = [klamath.library.Reference(struct_name=encoded_name, + xy=numpy.round([subpat.offset + dd]).astype(int), + colrow=None, + angle_deg=angle_deg, + invert_y=mirror_across_x, + mag=subpat.scale, + properties=properties) + for dd in rep.displacements] + refs += new_srefs + return refs -def _properties_to_annotations(properties: Mapping[int, bytes]) -> annotations_t: - if not properties: - return None +def _properties_to_annotations(properties: Dict[int, bytes]) -> annotations_t: return {str(k): [v.decode()] for k, v in properties.items()} -def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Mapping[int, bytes]: - if annotations is None: - return RO_EMPTY_DICT +def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> Dict[int, bytes]: cum_len = 0 props = {} for key, vals in annotations.items(): try: i = int(key) - except ValueError as err: - raise PatternError(f'Annotation key {key} is not convertable to an integer') from err - if not (0 < i <= 126): - raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,126])') + except ValueError: + raise PatternError(f'Annotation key {key} is not convertable to an integer') + if not (0 < i < 126): + raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])') val_strings = ' '.join(str(val) for val in vals) b = val_strings.encode() @@ -433,93 +434,138 @@ def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) - def _shapes_to_elements( - shapes: dict[layer_t, list[Shape]], + shapes: List[Shape], polygonize_paths: bool = False, - ) -> list[klamath.elements.Element]: - elements: list[klamath.elements.Element] = [] + ) -> List[klamath.elements.Element]: + elements: List[klamath.elements.Element] = [] # Add a Boundary element for each shape, and Path elements if necessary - for mlayer, sseq in shapes.items(): - layer, data_type = _mlayer2gds(mlayer) - for shape in sseq: - if shape.repetition is not None: - raise PatternError('Shape repetitions are not supported by GDS.' - ' Please call library.wrap_repeated_shapes() before writing to file.') + for shape in shapes: + layer, data_type = _mlayer2gds(shape.layer) + properties = _annotations_to_properties(shape.annotations, 128) + if isinstance(shape, Path) and not polygonize_paths: + xy = numpy.round(shape.vertices + shape.offset).astype(int) + width = numpy.round(shape.width).astype(int) + path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup - properties = _annotations_to_properties(shape.annotations, 128) - if isinstance(shape, Path) and not polygonize_paths: - xy = rint_cast(shape.vertices + shape.offset) - width = rint_cast(shape.width) - path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup + extension: Tuple[int, int] + if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None: + extension = tuple(shape.cap_extensions) # type: ignore + else: + extension = (0, 0) - extension: tuple[int, int] - if shape.cap == Path.Cap.SquareCustom and shape.cap_extensions is not None: - extension = tuple(shape.cap_extensions) # type: ignore - else: - extension = (0, 0) - - path = klamath.elements.Path( - layer=(layer, data_type), - xy=xy, - path_type=path_type, - width=int(width), - extension=extension, - properties=properties, - ) - elements.append(path) - elif isinstance(shape, Polygon): - polygon = shape + path = klamath.elements.Path(layer=(layer, data_type), + xy=xy, + path_type=path_type, + width=width, + extension=extension, + properties=properties) + elements.append(path) + elif isinstance(shape, Polygon): + polygon = shape + xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) + numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe') + xy_closed[-1] = xy_closed[0] + boundary = klamath.elements.Boundary(layer=(layer, data_type), + xy=xy_closed, + properties=properties) + elements.append(boundary) + else: + for polygon in shape.to_polygons(): xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe') xy_closed[-1] = xy_closed[0] - boundary = klamath.elements.Boundary( - layer=(layer, data_type), - xy=xy_closed, - properties=properties, - ) + boundary = klamath.elements.Boundary(layer=(layer, data_type), + xy=xy_closed, + properties=properties) elements.append(boundary) - else: - for polygon in shape.to_polygons(): - xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) - numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe') - xy_closed[-1] = xy_closed[0] - boundary = klamath.elements.Boundary( - layer=(layer, data_type), - xy=xy_closed, - properties=properties, - ) - elements.append(boundary) return elements -def _labels_to_texts(labels: dict[layer_t, list[Label]]) -> list[klamath.elements.Text]: +def _labels_to_texts(labels: List[Label]) -> List[klamath.elements.Text]: texts = [] - for mlayer, lseq in labels.items(): - layer, text_type = _mlayer2gds(mlayer) - for label in lseq: - properties = _annotations_to_properties(label.annotations, 128) - xy = rint_cast([label.offset]) - text = klamath.elements.Text( - layer=(layer, text_type), - xy=xy, - string=label.string.encode('ASCII'), - properties=properties, - presentation=0, # font number & alignment -- unused by us - angle_deg=0, # rotation -- unused by us - invert_y=False, # inversion -- unused by us - width=0, # stroke width -- unused by us - path_type=0, # text path endcaps, unused - mag=1, # size -- unused by us - ) - texts.append(text) + for label in labels: + properties = _annotations_to_properties(label.annotations, 128) + layer, text_type = _mlayer2gds(label.layer) + xy = numpy.round([label.offset]).astype(int) + text = klamath.elements.Text(layer=(layer, text_type), + xy=xy, + string=label.string.encode('ASCII'), + properties=properties, + presentation=0, # TODO maybe set some of these? + angle_deg=0, + invert_y=False, + width=0, + path_type=0, + mag=1) + texts.append(text) return texts +def disambiguate_pattern_names( + patterns: Sequence[Pattern], + max_name_length: int = 32, + suffix_length: int = 6, + dup_warn_filter: Optional[Callable[[str], bool]] = None, + ) -> None: + """ + Args: + patterns: List of patterns to disambiguate + max_name_length: Names longer than this will be truncated + suffix_length: Names which get truncated are truncated by this many extra characters. This is to + leave room for a suffix if one is necessary. + dup_warn_filter: (optional) Function for suppressing warnings about cell names changing. Receives + the cell name and returns `False` if the warning should be suppressed and `True` if it should + be displayed. Default displays all warnings. + """ + used_names = [] + for pat in set(patterns): + # Shorten names which already exceed max-length + if len(pat.name) > max_name_length: + shortened_name = pat.name[:max_name_length - suffix_length] + logger.warning(f'Pattern name "{pat.name}" is too long ({len(pat.name)}/{max_name_length} chars),\n' + + f' shortening to "{shortened_name}" before generating suffix') + else: + shortened_name = pat.name + + # Remove invalid characters + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', shortened_name) + + # Add a suffix that makes the name unique + i = 0 + suffixed_name = sanitized_name + while suffixed_name in used_names or suffixed_name == '': + suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII') + + suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A') + i += 1 + + if sanitized_name == '': + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') + elif suffixed_name != sanitized_name: + if dup_warn_filter is None or dup_warn_filter(pat.name): + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') + + # Encode into a byte-string and perform some final checks + encoded_name = suffixed_name.encode('ASCII') + if len(encoded_name) == 0: + # Should never happen since zero-length names are replaced + raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"') + if len(encoded_name) > max_name_length: + raise PatternError(f'Pattern name "{encoded_name!r}" length > {max_name_length} after encode,\n' + + f' originally "{pat.name}"') + + pat.name = suffixed_name + used_names.append(suffixed_name) + + def load_library( - stream: IO[bytes], + stream: BinaryIO, + tag: str, + is_secondary: Optional[Callable[[str], bool]] = None, *, full_load: bool = False, - postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None - ) -> tuple[LazyLibrary, dict[str, Any]]: + ) -> Tuple[Library, Dict[str, Any]]: """ Scan a GDSII stream to determine what structures are present, and create a library from them. This enables deferred reading of structures @@ -528,30 +574,36 @@ def load_library( Args: stream: Seekable stream. Position 0 should be the start of the file. - The caller should leave the stream open while the library - is still in use, since the library will need to access it - in order to read the structure contents. + The caller should leave the stream open while the library + is still in use, since the library will need to access it + in order to read the structure contents. + tag: Unique identifier that will be used to identify this data source + is_secondary: Function which takes a structure name and returns + True if the structure should only be used as a subcell + and not appear in the main Library interface. + Default always returns False. full_load: If True, force all structures to be read immediately rather - than as-needed. Since data is read sequentially from the file, this - will be faster than using the resulting library's `precache` method. - postprocess: If given, this function is used to post-process each - pattern *upon first load only*. + than as-needed. Since data is read sequentially from the file, + this will be faster than using the resulting library's + `precache` method. Returns: - LazyLibrary object, allowing for deferred load of structures. + Library object, allowing for deferred load of structures. Additional library info (dict, same format as from `read`). """ + if is_secondary is None: + def is_secondary(k: str) -> bool: + return False + assert(is_secondary is not None) + stream.seek(0) - lib = LazyLibrary() + lib = Library() if full_load: # Full load approach (immediately load everything) patterns, library_info = read(stream) for name, pattern in patterns.items(): - if postprocess is not None: - lib[name] = postprocess(lib, name, pattern) - else: - lib[name] = pattern + lib.set_const(name, tag, pattern, secondary=is_secondary(name)) return lib, library_info # Normal approach (scan and defer load) @@ -563,23 +615,21 @@ def load_library( def mkstruct(pos: int = pos, name: str = name) -> Pattern: stream.seek(pos) - pat = read_elements(stream, raw_mode=True) - if postprocess is not None: - pat = postprocess(lib, name, pat) - return pat + return read_elements(stream, name, raw_mode=True) - lib[name] = mkstruct + lib.set_value(name, tag, mkstruct, secondary=is_secondary(name)) return lib, library_info def load_libraryfile( - filename: str | pathlib.Path, + filename: Union[str, pathlib.Path], + tag: str, + is_secondary: Optional[Callable[[str], bool]] = None, *, use_mmap: bool = True, full_load: bool = False, - postprocess: Callable[[ILibraryView, str, Pattern], Pattern] | None = None - ) -> tuple[LazyLibrary, dict[str, Any]]: + ) -> Tuple[Library, Dict[str, Any]]: """ Wrapper for `load_library()` that takes a filename or path instead of a stream. @@ -590,65 +640,31 @@ def load_libraryfile( Args: path: filename or path to read from + tag: Unique identifier for library, see `load_library` + is_secondary: Function specifying subcess, see `load_library` use_mmap: If `True`, will attempt to memory-map the file instead of buffering. In the case of gzipped files, the file is decompressed into a python `bytes` object in memory and reopened as an `io.BytesIO` stream. full_load: If `True`, immediately loads all data. See `load_library`. - postprocess: Passed to `load_library` Returns: - LazyLibrary object, allowing for deferred load of structures. + Library object, allowing for deferred load of structures. Additional library info (dict, same format as from `read`). """ path = pathlib.Path(filename) - stream: IO[bytes] if is_gzipped(path): - if use_mmap: + if mmap: logger.info('Asked to mmap a gzipped file, reading into memory instead...') - gz_stream = gzip.open(path, mode='rb') # noqa: SIM115 - stream = io.BytesIO(gz_stream.read()) # type: ignore + base_stream = gzip.open(path, mode='rb') + stream = io.BytesIO(base_stream.read()) else: - gz_stream = gzip.open(path, mode='rb') # noqa: SIM115 - stream = io.BufferedReader(gz_stream) # type: ignore - else: # noqa: PLR5501 - if use_mmap: - base_stream = path.open(mode='rb', buffering=0) # noqa: SIM115 - stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) # type: ignore + base_stream = gzip.open(path, mode='rb') + stream = io.BufferedReader(base_stream) + else: + base_stream = open(path, mode='rb') + if mmap: + stream = mmap.mmap(base_stream.fileno(), 0, access=mmap.ACCESS_READ) else: - stream = path.open(mode='rb') # noqa: SIM115 - return load_library(stream, full_load=full_load, postprocess=postprocess) - - -def check_valid_names( - names: Iterable[str], - max_length: int = 32, - ) -> None: - """ - Check all provided names to see if they're valid GDSII cell names. - - Args: - names: Collection of names to check - max_length: Max allowed length - - """ - allowed_chars = set(string.ascii_letters + string.digits + '_?$') - - bad_chars = [ - name for name in names - if not set(name).issubset(allowed_chars) - ] - - bad_lengths = [ - name for name in names - if len(name) > max_length - ] - - if bad_chars: - logger.error('Names contain invalid characters:\n' + pformat(bad_chars)) - - if bad_lengths: - logger.error(f'Names too long (>{max_length}:\n' + pformat(bad_chars)) - - if bad_chars or bad_lengths: - raise LibraryError('Library contains invalid names, see log above') + stream = io.BufferedReader(base_stream) + return load_library(stream, tag, is_secondary) diff --git a/masque/file/klamath.py b/masque/file/klamath.py new file mode 100644 index 0000000..2208f3d --- /dev/null +++ b/masque/file/klamath.py @@ -0,0 +1,2 @@ +# FOr backwards compatibility +from .gdsii import * diff --git a/masque/file/oasis.py b/masque/file/oasis.py index 672af25..27c8b71 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -10,36 +10,33 @@ Note that OASIS references follow the same convention as `masque`, Scaling, rotation, and mirroring apply to individual instances, not grid vectors or offsets. - -Notes: - * Gzip modification time is set to 0 (start of current epoch, usually 1970-01-01) """ -from typing import Any, IO, cast -from collections.abc import Sequence, Iterable, Mapping, Callable +from typing import List, Any, Dict, Tuple, Callable, Union, Sequence, Iterable, Optional +import re +import io +import copy +import base64 +import struct import logging import pathlib import gzip -import string -from pprint import pformat import numpy -from numpy.typing import ArrayLike, NDArray import fatamorgana import fatamorgana.records as fatrec from fatamorgana.basic import PathExtensionScheme, AString, NString, PropStringReference -from .utils import is_gzipped, tmpfile -from .. import Pattern, Ref, PatternError, LibraryError, Label, Shape -from ..library import Library, ILibrary -from ..shapes import Path, Circle +from .utils import clean_pattern_vertices, is_gzipped +from .. import Pattern, SubPattern, PatternError, Label, Shape +from ..shapes import Polygon, Path, Circle from ..repetition import Grid, Arbitrary, Repetition -from ..utils import layer_t, annotations_t +from ..utils import layer_t, normalize_mirror, annotations_t logger = logging.getLogger(__name__) -logger.warning('OASIS support is experimental!') +logger.warning('OASIS support is experimental and mostly untested!') path_cap_map = { @@ -48,23 +45,21 @@ path_cap_map = { PathExtensionScheme.Arbitrary: Path.Cap.SquareCustom, } -#TODO implement more shape types in OASIS? - -def rint_cast(val: ArrayLike) -> NDArray[numpy.int64]: - return numpy.rint(val).astype(numpy.int64) - +#TODO implement more shape types? def build( - library: Mapping[str, Pattern], # NOTE: Pattern here should be treated as immutable! + patterns: Union[Pattern, Sequence[Pattern]], units_per_micron: int, - layer_map: dict[str, int | tuple[int, int]] | None = None, + layer_map: Optional[Dict[str, Union[int, Tuple[int, int]]]] = None, *, - annotations: annotations_t | None = None, + modify_originals: bool = False, + disambiguate_func: Optional[Callable[[Iterable[Pattern]], None]] = None, + annotations: Optional[annotations_t] = None, ) -> fatamorgana.OasisLayout: """ - Convert a collection of {name: Pattern} pairs to an OASIS stream, writing patterns - as OASIS cells, refs as Placement records, and mapping other shapes and labels - to equivalent record types (Polygon, Path, Circle, Text). + Convert a `Pattern` or list of patterns to an OASIS stream, writing patterns + as OASIS cells, subpatterns as Placement records, and other shapes and labels + mapped to equivalent record types (Polygon, Path, Circle, Text). Other shape types may be converted to polygons if no equivalent record type exists (or is not implemented here yet). @@ -76,17 +71,14 @@ def build( If a layer map is provided, layer strings will be converted automatically, and layer names will be written to the file. - Other functions you may want to call: - - `masque.file.oasis.check_valid_names(library.keys())` to check for invalid names - - `library.dangling_refs()` to check for references to missing patterns - - `pattern.polygonize()` for any patterns with shapes other - than `masque.shapes.Polygon`, `masque.shapes.Path`, or `masque.shapes.Circle` + If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` + prior to calling this function. Args: - library: A {name: Pattern} mapping of patterns to write. + patterns: A Pattern or list of patterns to convert. units_per_micron: Written into the OASIS file, number of grid steps per micrometer. All distances are assumed to be an integer multiple of the grid step, and are stored as such. - layer_map: dictionary which translates layer names into layer numbers. If this argument is + layer_map: Dictionary which translates layer names into layer numbers. If this argument is provided, input shapes and labels are allowed to have layer names instead of numbers. It is assumed that geometry and text share the same layer names, and each name is assigned only to a single layer (not a range). @@ -94,23 +86,31 @@ def build( into numbers, omit this argument, and manually generate the required `fatamorgana.records.LayerName` entries. Default is an empty dict (no names provided). + modify_originals: If `True`, the original pattern is modified as part of the writing + process. Otherwise, a copy is made and `deepunlock()`-ed. + Default `False`. + disambiguate_func: Function which takes a list of patterns and alters them + to make their names valid and unique. Default is `disambiguate_pattern_names`. annotations: dictionary of key-value pairs which are saved as library-level properties Returns: `fatamorgana.OasisLayout` """ - if not isinstance(library, ILibrary): - if isinstance(library, dict): - library = Library(library) - else: - library = Library(dict(library)) + if isinstance(patterns, Pattern): + patterns = [patterns] if layer_map is None: layer_map = {} + if disambiguate_func is None: + disambiguate_func = disambiguate_pattern_names + if annotations is None: annotations = {} + if not modify_originals: + patterns = [p.deepunlock() for p in copy.deepcopy(patterns)] + # Create library lib = fatamorgana.OasisLayout(unit=units_per_micron, validation=None) lib.properties = annotations_to_properties(annotations) @@ -119,38 +119,44 @@ def build( for name, layer_num in layer_map.items(): layer, data_type = _mlayer2oas(layer_num) lib.layers += [ - fatrec.LayerName( - nstring=name, - layer_interval=(layer, layer), - type_interval=(data_type, data_type), - is_textlayer=tt, - ) + fatrec.LayerName(nstring=name, + layer_interval=(layer, layer), + type_interval=(data_type, data_type), + is_textlayer=tt) for tt in (True, False)] - def layer2oas(mlayer: layer_t) -> tuple[int, int]: - assert layer_map is not None + def layer2oas(mlayer: layer_t) -> Tuple[int, int]: + assert(layer_map is not None) layer_num = layer_map[mlayer] if isinstance(mlayer, str) else mlayer return _mlayer2oas(layer_num) else: layer2oas = _mlayer2oas + # Get a dict of id(pattern) -> pattern + patterns_by_id = {id(pattern): pattern for pattern in patterns} + for pattern in patterns: + for i, p in pattern.referenced_patterns_by_id().items(): + patterns_by_id[i] = p + + disambiguate_func(patterns_by_id.values()) + # Now create a structure for each pattern - for name, pat in library.items(): - structure = fatamorgana.Cell(name=name) + for pat in patterns_by_id.values(): + structure = fatamorgana.Cell(name=pat.name) lib.cells.append(structure) structure.properties += annotations_to_properties(pat.annotations) structure.geometry += _shapes_to_elements(pat.shapes, layer2oas) structure.geometry += _labels_to_texts(pat.labels, layer2oas) - structure.placements += _refs_to_placements(pat.refs) + structure.placements += _subpatterns_to_placements(pat.subpatterns) return lib def write( - library: Mapping[str, Pattern], # NOTE: Pattern here should be treated as immutable! - stream: IO[bytes], + patterns: Union[Sequence[Pattern], Pattern], + stream: io.BufferedIOBase, *args, **kwargs, ) -> None: @@ -159,18 +165,18 @@ def write( for details. Args: - library: A {name: Pattern} mapping of patterns to write. + patterns: A Pattern or list of patterns to write to file. stream: Stream to write to. *args: passed to `oasis.build()` **kwargs: passed to `oasis.build()` """ - lib = build(library, *args, **kwargs) + lib = build(patterns, *args, **kwargs) lib.write(stream) def writefile( - library: Mapping[str, Pattern], # NOTE: Pattern here should be treated as immutable! - filename: str | pathlib.Path, + patterns: Union[Sequence[Pattern], Pattern], + filename: Union[str, pathlib.Path], *args, **kwargs, ) -> None: @@ -180,33 +186,26 @@ def writefile( Will automatically compress the file if it has a .gz suffix. Args: - library: A {name: Pattern} mapping of patterns to write. + patterns: `Pattern` or list of patterns to save filename: Filename to save to. *args: passed to `oasis.write` **kwargs: passed to `oasis.write` """ path = pathlib.Path(filename) + if path.suffix == '.gz': + open_func: Callable = gzip.open + else: + open_func = open - with tmpfile(path) as base_stream: - streams: tuple[Any, ...] = (base_stream,) - if path.suffix == '.gz': - stream = cast('IO[bytes]', gzip.GzipFile(filename='', mtime=0, fileobj=base_stream, mode='wb')) - streams += (stream,) - else: - stream = base_stream - - try: - write(library, stream, *args, **kwargs) - finally: - for ss in streams: - ss.close() + with io.BufferedWriter(open_func(path, mode='wb')) as stream: + write(patterns, stream, *args, **kwargs) def readfile( - filename: str | pathlib.Path, + filename: Union[str, pathlib.Path], *args, **kwargs, - ) -> tuple[Library, dict[str, Any]]: + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: """ Wrapper for `oasis.read()` that takes a filename or path instead of a stream. @@ -223,18 +222,19 @@ def readfile( else: open_func = open - with open_func(path, mode='rb') as stream: + with io.BufferedReader(open_func(path, mode='rb')) as stream: results = read(stream, *args, **kwargs) return results def read( - stream: IO[bytes], - ) -> tuple[Library, dict[str, Any]]: + stream: io.BufferedIOBase, + clean_vertices: bool = True, + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: """ Read a OASIS file and translate it into a dict of Pattern objects. OASIS cells are translated into Pattern objects; Polygons are translated into polygons, and Placements - are translated into Ref objects. + are translated into SubPattern objects. Additional library info is returned in a dict, containing: 'units_per_micrometer': number of database units per micrometer (all values are in database units) @@ -243,15 +243,18 @@ def read( Args: stream: Stream to read from. + clean_vertices: If `True`, remove any redundant vertices when loading polygons. + The cleaning process removes any polygons with zero area or <3 vertices. + Default `True`. Returns: - - dict of `pattern_name`:`Pattern`s generated from OASIS cells - - dict of OASIS library info + - Dict of `pattern_name`:`Pattern`s generated from OASIS cells + - Dict of OASIS library info """ lib = fatamorgana.OasisLayout.read(stream) - library_info: dict[str, Any] = { + library_info: Dict[str, Any] = { 'units_per_micrometer': lib.unit, 'annotations': properties_to_annotations(lib.properties, lib.propnames, lib.propstrings), } @@ -261,76 +264,72 @@ def read( layer_map[str(layer_name.nstring)] = layer_name library_info['layer_map'] = layer_map - mlib = Library() + patterns = [] for cell in lib.cells: if isinstance(cell.name, int): cell_name = lib.cellnames[cell.name].nstring.string else: cell_name = cell.name.string - pat = Pattern() + pat = Pattern(name=cell_name) for element in cell.geometry: if isinstance(element, fatrec.XElement): logger.warning('Skipping XElement record') # note XELEMENT has no repetition continue - assert not isinstance(element.repetition, fatamorgana.ReuseRepetition) + assert(not isinstance(element.repetition, fatamorgana.ReuseRepetition)) repetition = repetition_fata2masq(element.repetition) # Switch based on element type: if isinstance(element, fatrec.Polygon): - # Drop last point (`fatamorgana` returns explicity closed list; we use implicit close) - # also need `cumsum` to convert from deltas to locations - vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list()[:-1])), axis=0) - + vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0) annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - pat.polygon( - vertices=vertices, - layer=element.get_layer_tuple(), - offset=element.get_xy(), - annotations=annotations, - repetition=repetition, - ) + poly = Polygon(vertices=vertices, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + annotations=annotations, + repetition=repetition) + + pat.shapes.append(poly) + elif isinstance(element, fatrec.Path): vertices = numpy.cumsum(numpy.vstack(((0, 0), element.get_point_list())), axis=0) cap_start = path_cap_map[element.get_extension_start()[0]] cap_end = path_cap_map[element.get_extension_end()[0]] if cap_start != cap_end: - raise PatternError('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types + raise Exception('masque does not support multiple cap types on a single path.') # TODO handle multiple cap types cap = cap_start - path_args: dict[str, Any] = {} + path_args: Dict[str, Any] = {} if cap == Path.Cap.SquareCustom: - path_args['cap_extensions'] = numpy.array(( - element.get_extension_start()[1], - element.get_extension_end()[1], - )) + path_args['cap_extensions'] = numpy.array((element.get_extension_start()[1], + element.get_extension_end()[1])) annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - pat.path( - vertices=vertices, - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - annotations=annotations, - width=element.get_half_width() * 2, - cap=cap, - **path_args, - ) + path = Path(vertices=vertices, + layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + width=element.get_half_width() * 2, + cap=cap, + **path_args) + + pat.shapes.append(path) elif isinstance(element, fatrec.Rectangle): width = element.get_width() height = element.get_height() annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - pat.polygon( - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), - annotations=annotations, - ) + rect = Polygon(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + vertices=numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (width, height), + annotations=annotations, + ) + pat.shapes.append(rect) elif isinstance(element, fatrec.Trapezoid): vertices = numpy.array(((0, 0), (1, 0), (1, 1), (0, 1))) * (element.get_width(), element.get_height()) @@ -358,13 +357,13 @@ def read( vertices[2, 0] -= b annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - pat.polygon( - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - vertices=vertices, - annotations=annotations, - ) + trapz = Polygon(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + vertices=vertices, + annotations=annotations, + ) + pat.shapes.append(trapz) elif isinstance(element, fatrec.CTrapezoid): cttype = element.get_ctrapezoid_type() @@ -413,24 +412,22 @@ def read( vertices[0, 1] += width annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - pat.polygon( - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - vertices=vertices, - annotations=annotations, - ) + ctrapz = Polygon(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + vertices=vertices, + annotations=annotations, + ) + pat.shapes.append(ctrapz) elif isinstance(element, fatrec.Circle): annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) - layer = element.get_layer_tuple() - circle = Circle( - offset=element.get_xy(), - repetition=repetition, - annotations=annotations, - radius=float(element.get_radius()), - ) - pat.shapes[layer].append(circle) + circle = Circle(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + radius=float(element.get_radius())) + pat.shapes.append(circle) elif isinstance(element, fatrec.Text): annotations = properties_to_annotations(element.properties, lib.propnames, lib.propstrings) @@ -439,30 +436,38 @@ def read( string = lib.textstrings[str_or_ref].string else: string = str_or_ref.string - pat.label( - layer=element.get_layer_tuple(), - offset=element.get_xy(), - repetition=repetition, - annotations=annotations, - string=string, - ) + label = Label(layer=element.get_layer_tuple(), + offset=element.get_xy(), + repetition=repetition, + annotations=annotations, + string=string) + pat.labels.append(label) else: logger.warning(f'Skipping record {element} (unimplemented)') continue for placement in cell.placements: - target, ref = _placement_to_ref(placement, lib) - if isinstance(target, int): - target = lib.cellnames[target].nstring.string - pat.refs[target].append(ref) + pat.subpatterns.append(_placement_to_subpat(placement, lib)) - mlib[cell_name] = pat + if clean_vertices: + clean_pattern_vertices(pat) + patterns.append(pat) - return mlib, library_info + # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries + # according to the subpattern.identifier (which is deleted after use). + patterns_dict = dict(((p.name, p) for p in patterns)) + for p in patterns_dict.values(): + for sp in p.subpatterns: + ident = sp.identifier[0] + name = ident if isinstance(ident, str) else lib.cellnames[ident].nstring.string + sp.pattern = patterns_dict[name] + del sp.identifier + + return patterns_dict, library_info -def _mlayer2oas(mlayer: layer_t) -> tuple[int, int]: +def _mlayer2oas(mlayer: layer_t) -> Tuple[int, int]: """ Helper to turn a layer tuple-or-int into a layer and datatype""" if isinstance(mlayer, int): layer = mlayer @@ -474,163 +479,182 @@ def _mlayer2oas(mlayer: layer_t) -> tuple[int, int]: else: data_type = 0 else: - raise PatternError(f'Invalid layer for OASIS: {mlayer}. Note that OASIS layers cannot be ' + raise PatternError(f'Invalid layer for OASIS: {layer}. Note that OASIS layers cannot be ' f'strings unless a layer map is provided.') return layer, data_type -def _placement_to_ref(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> tuple[int | str, Ref]: +def _placement_to_subpat(placement: fatrec.Placement, lib: fatamorgana.OasisLayout) -> SubPattern: """ - Helper function to create a Ref from a placment. Also returns the placement name (or id). + Helper function to create a SubPattern from a placment. Sets subpat.pattern to None + and sets the instance .identifier to (struct_name,). """ - assert not isinstance(placement.repetition, fatamorgana.ReuseRepetition) + assert(not isinstance(placement.repetition, fatamorgana.ReuseRepetition)) xy = numpy.array((placement.x, placement.y)) mag = placement.magnification if placement.magnification is not None else 1 - pname = placement.get_name() - name: int | str = pname if isinstance(pname, int) else pname.string # TODO deal with referenced names - + name = pname if isinstance(pname, int) else pname.string annotations = properties_to_annotations(placement.properties, lib.propnames, lib.propstrings) if placement.angle is None: rotation = 0 else: rotation = numpy.deg2rad(float(placement.angle)) - ref = Ref( - offset=xy, - mirrored=placement.flip, - rotation=rotation, - scale=float(mag), - repetition=repetition_fata2masq(placement.repetition), - annotations=annotations, - ) - return name, ref + subpat = SubPattern(offset=xy, + pattern=None, + mirrored=(placement.flip, False), + rotation=rotation, + scale=float(mag), + identifier=(name,), + repetition=repetition_fata2masq(placement.repetition), + annotations=annotations) + return subpat -def _refs_to_placements( - refs: dict[str | None, list[Ref]], - ) -> list[fatrec.Placement]: - placements = [] - for target, rseq in refs.items(): - if target is None: +def _subpatterns_to_placements( + subpatterns: List[SubPattern], + ) -> List[fatrec.Placement]: + refs = [] + for subpat in subpatterns: + if subpat.pattern is None: continue - for ref in rseq: - # Note: OASIS also mirrors first and rotates second - frep, rep_offset = repetition_masq2fata(ref.repetition) - offset = rint_cast(ref.offset + rep_offset) - angle = numpy.rad2deg(ref.rotation) % 360 - placement = fatrec.Placement( - name=target, - flip=ref.mirrored, - angle=angle, - magnification=ref.scale, - properties=annotations_to_properties(ref.annotations), - x=offset[0], - y=offset[1], - repetition=frep, - ) + # Note: OASIS mirrors first and rotates second + mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored) + frep, rep_offset = repetition_masq2fata(subpat.repetition) - placements.append(placement) - return placements + offset = numpy.round(subpat.offset + rep_offset).astype(int) + angle = numpy.rad2deg(subpat.rotation + extra_angle) % 360 + ref = fatrec.Placement( + name=subpat.pattern.name, + flip=mirror_across_x, + angle=angle, + magnification=subpat.scale, + properties=annotations_to_properties(subpat.annotations), + x=offset[0], + y=offset[1], + repetition=frep) + + refs.append(ref) + return refs def _shapes_to_elements( - shapes: dict[layer_t, list[Shape]], - layer2oas: Callable[[layer_t], tuple[int, int]], - ) -> list[fatrec.Polygon | fatrec.Path | fatrec.Circle]: + shapes: List[Shape], + layer2oas: Callable[[layer_t], Tuple[int, int]], + ) -> List[Union[fatrec.Polygon, fatrec.Path, fatrec.Circle]]: # Add a Polygon record for each shape, and Path elements if necessary - elements: list[fatrec.Polygon | fatrec.Path | fatrec.Circle] = [] - for mlayer, sseq in shapes.items(): - layer, datatype = layer2oas(mlayer) - for shape in sseq: - repetition, rep_offset = repetition_masq2fata(shape.repetition) - properties = annotations_to_properties(shape.annotations) - if isinstance(shape, Circle): - offset = rint_cast(shape.offset + rep_offset) - radius = rint_cast(shape.radius) - circle = fatrec.Circle( - layer=layer, - datatype=datatype, - radius=cast('int', radius), - x=offset[0], - y=offset[1], - properties=properties, - repetition=repetition, - ) - elements.append(circle) - elif isinstance(shape, Path): - xy = rint_cast(shape.offset + shape.vertices[0] + rep_offset) - deltas = rint_cast(numpy.diff(shape.vertices, axis=0)) - half_width = rint_cast(shape.width / 2) - path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup - extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None) - extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None) - path = fatrec.Path( - layer=layer, - datatype=datatype, - point_list=cast('Sequence[Sequence[int]]', deltas), - half_width=cast('int', half_width), - x=xy[0], - y=xy[1], - extension_start=extension_start, # TODO implement multiple cap types? - extension_end=extension_end, - properties=properties, - repetition=repetition, - ) - elements.append(path) - else: - for polygon in shape.to_polygons(): - xy = rint_cast(polygon.offset + polygon.vertices[0] + rep_offset) - points = rint_cast(numpy.diff(polygon.vertices, axis=0)) - elements.append(fatrec.Polygon( - layer=layer, - datatype=datatype, - x=xy[0], - y=xy[1], - point_list=cast('list[list[int]]', points), - properties=properties, - repetition=repetition, - )) + elements: List[Union[fatrec.Polygon, fatrec.Path, fatrec.Circle]] = [] + for shape in shapes: + layer, datatype = layer2oas(shape.layer) + repetition, rep_offset = repetition_masq2fata(shape.repetition) + properties = annotations_to_properties(shape.annotations) + if isinstance(shape, Circle): + offset = numpy.round(shape.offset + rep_offset).astype(int) + radius = numpy.round(shape.radius).astype(int) + circle = fatrec.Circle(layer=layer, + datatype=datatype, + radius=radius, + x=offset[0], + y=offset[1], + properties=properties, + repetition=repetition) + elements.append(circle) + elif isinstance(shape, Path): + xy = numpy.round(shape.offset + shape.vertices[0] + rep_offset).astype(int) + deltas = numpy.round(numpy.diff(shape.vertices, axis=0)).astype(int) + half_width = numpy.round(shape.width / 2).astype(int) + path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup + extension_start = (path_type, shape.cap_extensions[0] if shape.cap_extensions is not None else None) + extension_end = (path_type, shape.cap_extensions[1] if shape.cap_extensions is not None else None) + path = fatrec.Path(layer=layer, + datatype=datatype, + point_list=deltas, + half_width=half_width, + x=xy[0], + y=xy[1], + extension_start=extension_start, # TODO implement multiple cap types? + extension_end=extension_end, + properties=properties, + repetition=repetition, + ) + elements.append(path) + else: + for polygon in shape.to_polygons(): + xy = numpy.round(polygon.offset + polygon.vertices[0] + rep_offset).astype(int) + points = numpy.round(numpy.diff(polygon.vertices, axis=0)).astype(int) + elements.append(fatrec.Polygon(layer=layer, + datatype=datatype, + x=xy[0], + y=xy[1], + point_list=points, + properties=properties, + repetition=repetition)) return elements def _labels_to_texts( - labels: dict[layer_t, list[Label]], - layer2oas: Callable[[layer_t], tuple[int, int]], - ) -> list[fatrec.Text]: + labels: List[Label], + layer2oas: Callable[[layer_t], Tuple[int, int]], + ) -> List[fatrec.Text]: texts = [] - for mlayer, lseq in labels.items(): - layer, datatype = layer2oas(mlayer) - for label in lseq: - repetition, rep_offset = repetition_masq2fata(label.repetition) - xy = rint_cast(label.offset + rep_offset) - properties = annotations_to_properties(label.annotations) - texts.append(fatrec.Text( - layer=layer, - datatype=datatype, - x=xy[0], - y=xy[1], - string=label.string, - properties=properties, - repetition=repetition, - )) + for label in labels: + layer, datatype = layer2oas(label.layer) + repetition, rep_offset = repetition_masq2fata(label.repetition) + xy = numpy.round(label.offset + rep_offset).astype(int) + properties = annotations_to_properties(label.annotations) + texts.append(fatrec.Text(layer=layer, + datatype=datatype, + x=xy[0], + y=xy[1], + string=label.string, + properties=properties, + repetition=repetition)) return texts +def disambiguate_pattern_names( + patterns, + dup_warn_filter: Callable[[str], bool] = None, # If returns False, don't warn about this name + ) -> None: + used_names = [] + for pat in patterns: + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', pat.name) + + i = 0 + suffixed_name = sanitized_name + while suffixed_name in used_names or suffixed_name == '': + suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII') + + suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A') + i += 1 + + if sanitized_name == '': + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') + elif suffixed_name != sanitized_name: + if dup_warn_filter is None or dup_warn_filter(pat.name): + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') + + if len(suffixed_name) == 0: + # Should never happen since zero-length names are replaced + raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"') + + pat.name = suffixed_name + used_names.append(suffixed_name) + + def repetition_fata2masq( - rep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None, - ) -> Repetition | None: - mrep: Repetition | None + rep: Union[fatamorgana.GridRepetition, fatamorgana.ArbitraryRepetition, None], + ) -> Optional[Repetition]: + mrep: Optional[Repetition] if isinstance(rep, fatamorgana.GridRepetition): mrep = Grid(a_vector=rep.a_vector, b_vector=rep.b_vector, a_count=rep.a_count, b_count=rep.b_count) elif isinstance(rep, fatamorgana.ArbitraryRepetition): - displacements = numpy.cumsum(numpy.column_stack(( - rep.x_displacements, - rep.y_displacements, - )), axis=0) + displacements = numpy.cumsum(numpy.column_stack((rep.x_displacements, + rep.y_displacements)), axis=0) displacements = numpy.vstack(([0, 0], displacements)) mrep = Arbitrary(displacements) elif rep is None: @@ -639,40 +663,38 @@ def repetition_fata2masq( def repetition_masq2fata( - rep: Repetition | None, - ) -> tuple[ - fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None, - tuple[int, int] - ]: - frep: fatamorgana.GridRepetition | fatamorgana.ArbitraryRepetition | None + rep: Optional[Repetition], + ) -> Tuple[Union[fatamorgana.GridRepetition, + fatamorgana.ArbitraryRepetition, + None], + Tuple[int, int]]: + frep: Union[fatamorgana.GridRepetition, fatamorgana.ArbitraryRepetition, None] if isinstance(rep, Grid): a_vector = rint_cast(rep.a_vector) b_vector = rint_cast(rep.b_vector) if rep.b_vector is not None else None a_count = rint_cast(rep.a_count) b_count = rint_cast(rep.b_count) if rep.b_count is not None else None frep = fatamorgana.GridRepetition( - a_vector=cast('list[int]', a_vector), - b_vector=cast('list[int] | None', b_vector), - a_count=cast('int', a_count), - b_count=cast('int | None', b_count), + a_vector=a_vector, + b_vector=b_vector, + a_count=a_count, + b_count=b_count, ) offset = (0, 0) elif isinstance(rep, Arbitrary): diffs = numpy.diff(rep.displacements, axis=0) diff_ints = rint_cast(diffs) - frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) # type: ignore - offset = tuple(rep.displacements[0, :]) + frep = fatamorgana.ArbitraryRepetition(diff_ints[:, 0], diff_ints[:, 1]) + offset = rep.displacements[0, :] else: - assert rep is None + assert(rep is None) frep = None offset = (0, 0) return frep, offset -def annotations_to_properties(annotations: annotations_t) -> list[fatrec.Property]: +def annotations_to_properties(annotations: annotations_t) -> List[fatrec.Property]: #TODO determine is_standard based on key? - if annotations is None: - return [] properties = [] for key, values in annotations.items(): vals = [AString(v) if isinstance(v, str) else v @@ -682,24 +704,24 @@ def annotations_to_properties(annotations: annotations_t) -> list[fatrec.Propert def properties_to_annotations( - properties: list[fatrec.Property], - propnames: dict[int, NString], - propstrings: dict[int, AString], + properties: List[fatrec.Property], + propnames: Dict[int, NString], + propstrings: Dict[int, AString], ) -> annotations_t: annotations = {} for proprec in properties: - assert proprec.name is not None + assert(proprec.name is not None) if isinstance(proprec.name, int): key = propnames[proprec.name].string else: key = proprec.name.string - values: list[str | float | int] = [] + values: List[Union[str, float, int]] = [] - assert proprec.values is not None + assert(proprec.values is not None) for value in proprec.values: - if isinstance(value, float | int): + if isinstance(value, (float, int)): values.append(value) - elif isinstance(value, NString | AString): + elif isinstance(value, (NString, AString)): values.append(value.string) elif isinstance(value, PropStringReference): values.append(propstrings[value.ref].string) # dereference @@ -713,25 +735,3 @@ def properties_to_annotations( properties = [fatrec.Property(key, vals, is_standard=False) for key, vals in annotations.items()] return properties - - -def check_valid_names( - names: Iterable[str], - ) -> None: - """ - Check all provided names to see if they're valid GDSII cell names. - - Args: - names: Collection of names to check - max_length: Max allowed length - - """ - allowed_chars = set(string.ascii_letters + string.digits + string.punctuation + ' ') - - bad_chars = [ - name for name in names - if not set(name).issubset(allowed_chars) - ] - - if bad_chars: - raise LibraryError('Names contain invalid characters:\n' + pformat(bad_chars)) diff --git a/masque/file/python_gdsii.py b/masque/file/python_gdsii.py new file mode 100644 index 0000000..6b89abc --- /dev/null +++ b/masque/file/python_gdsii.py @@ -0,0 +1,580 @@ +""" +GDSII file format readers and writers using python-gdsii + +Note that GDSII references follow the same convention as `masque`, + with this order of operations: + 1. Mirroring + 2. Rotation + 3. Scaling + 4. Offset and array expansion (no mirroring/rotation/scaling applied to offsets) + + Scaling, rotation, and mirroring apply to individual instances, not grid + vectors or offsets. + +Notes: + * absolute positioning is not supported + * PLEX is not supported + * ELFLAGS are not supported + * GDS does not support library- or structure-level annotations +""" +from typing import List, Any, Dict, Tuple, Callable, Union, Iterable, Optional +from typing import Sequence +import re +import io +import copy +import base64 +import struct +import logging +import pathlib +import gzip + +import numpy +from numpy.typing import NDArray, ArrayLike +# python-gdsii +import gdsii.library #type: ignore +import gdsii.structure #type: ignore +import gdsii.elements #type: ignore + +from .utils import clean_pattern_vertices, is_gzipped +from .. import Pattern, SubPattern, PatternError, Label, Shape +from ..shapes import Polygon, Path +from ..repetition import Grid +from ..utils import get_bit, set_bit, layer_t, normalize_mirror, annotations_t + + +logger = logging.getLogger(__name__) + + +path_cap_map = { + None: Path.Cap.Flush, + 0: Path.Cap.Flush, + 1: Path.Cap.Circle, + 2: Path.Cap.Square, + 4: Path.Cap.SquareCustom, + } + + +def rint_cast(val: ArrayLike) -> NDArray[numpy.int32]: + return numpy.rint(val, dtype=numpy.int32, casting='unsafe') + + +def build( + patterns: Union[Pattern, Sequence[Pattern]], + meters_per_unit: float, + logical_units_per_unit: float = 1, + library_name: str = 'masque-gdsii-write', + *, + modify_originals: bool = False, + disambiguate_func: Callable[[Iterable[Pattern]], None] = None, + ) -> gdsii.library.Library: + """ + Convert a `Pattern` or list of patterns to a GDSII stream, by first calling + `.polygonize()` to change the shapes into polygons, and then writing patterns + as GDSII structures, polygons as boundary elements, and subpatterns as structure + references (sref). + + For each shape, + layer is chosen to be equal to `shape.layer` if it is an int, + or `shape.layer[0]` if it is a tuple + datatype is chosen to be `shape.layer[1]` if available, + otherwise `0` + + It is often a good idea to run `pattern.subpatternize()` prior to calling this function, + especially if calling `.polygonize()` will result in very many vertices. + + If you want pattern polygonized with non-default arguments, just call `pattern.polygonize()` + prior to calling this function. + + Args: + patterns: A Pattern or list of patterns to convert. + meters_per_unit: Written into the GDSII file, meters per (database) length unit. + All distances are assumed to be an integer multiple of this unit, and are stored as such. + logical_units_per_unit: Written into the GDSII file. Allows the GDSII to specify a + "logical" unit which is different from the "database" unit, for display purposes. + Default `1`. + library_name: Library name written into the GDSII file. + Default 'masque-gdsii-write'. + modify_originals: If `True`, the original pattern is modified as part of the writing + process. Otherwise, a copy is made and `deepunlock()`-ed. + Default `False`. + disambiguate_func: Function which takes a list of patterns and alters them + to make their names valid and unique. Default is `disambiguate_pattern_names`, which + attempts to adhere to the GDSII standard as well as possible. + WARNING: No additional error checking is performed on the results. + + Returns: + `gdsii.library.Library` + """ + if isinstance(patterns, Pattern): + patterns = [patterns] + + if disambiguate_func is None: + disambiguate_func = disambiguate_pattern_names # type: ignore + assert(disambiguate_func is not None) # placate mypy + + if not modify_originals: + patterns = [p.deepunlock() for p in copy.deepcopy(patterns)] + + patterns = [p.wrap_repeated_shapes() for p in patterns] + + # Create library + lib = gdsii.library.Library(version=600, + name=library_name.encode('ASCII'), + logical_unit=logical_units_per_unit, + physical_unit=meters_per_unit) + + # Get a dict of id(pattern) -> pattern + patterns_by_id = {id(pattern): pattern for pattern in patterns} + for pattern in patterns: + for i, p in pattern.referenced_patterns_by_id().items(): + patterns_by_id[i] = p + + disambiguate_func(patterns_by_id.values()) + + # Now create a structure for each pattern, and add in any Boundary and SREF elements + for pat in patterns_by_id.values(): + structure = gdsii.structure.Structure(name=pat.name.encode('ASCII')) + lib.append(structure) + + structure += _shapes_to_elements(pat.shapes) + structure += _labels_to_texts(pat.labels) + structure += _subpatterns_to_refs(pat.subpatterns) + + return lib + + +def write( + patterns: Union[Pattern, Sequence[Pattern]], + stream: io.BufferedIOBase, + *args, + **kwargs, + ) -> None: + """ + Write a `Pattern` or list of patterns to a GDSII file. + See `masque.file.gdsii.build()` for details. + + Args: + patterns: A Pattern or list of patterns to write to file. + stream: Stream to write to. + *args: passed to `masque.file.gdsii.build()` + **kwargs: passed to `masque.file.gdsii.build()` + """ + lib = build(patterns, *args, **kwargs) + lib.save(stream) + return + +def writefile( + patterns: Union[Sequence[Pattern], Pattern], + filename: Union[str, pathlib.Path], + *args, + **kwargs, + ) -> None: + """ + Wrapper for `masque.file.gdsii.write()` that takes a filename or path instead of a stream. + + Will automatically compress the file if it has a .gz suffix. + + Args: + patterns: `Pattern` or list of patterns to save + filename: Filename to save to. + *args: passed to `masque.file.gdsii.write` + **kwargs: passed to `masque.file.gdsii.write` + """ + path = pathlib.Path(filename) + if path.suffix == '.gz': + open_func: Callable = gzip.open + else: + open_func = open + + with io.BufferedWriter(open_func(path, mode='wb')) as stream: + write(patterns, stream, *args, **kwargs) + + +def readfile( + filename: Union[str, pathlib.Path], + *args, + **kwargs, + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: + """ + Wrapper for `masque.file.gdsii.read()` that takes a filename or path instead of a stream. + + Will automatically decompress gzipped files. + + Args: + filename: Filename to save to. + *args: passed to `masque.file.gdsii.read` + **kwargs: passed to `masque.file.gdsii.read` + """ + path = pathlib.Path(filename) + if is_gzipped(path): + open_func: Callable = gzip.open + else: + open_func = open + + with io.BufferedReader(open_func(path, mode='rb')) as stream: + results = read(stream, *args, **kwargs) + return results + + +def read( + stream: io.BufferedIOBase, + clean_vertices: bool = True, + ) -> Tuple[Dict[str, Pattern], Dict[str, Any]]: + """ + Read a gdsii file and translate it into a dict of Pattern objects. GDSII structures are + translated into Pattern objects; boundaries are translated into polygons, and srefs and arefs + are translated into SubPattern objects. + + Additional library info is returned in a dict, containing: + 'name': name of the library + 'meters_per_unit': number of meters per database unit (all values are in database units) + 'logical_units_per_unit': number of "logical" units displayed by layout tools (typically microns) + per database unit + + Args: + stream: Stream to read from. + clean_vertices: If `True`, remove any redundant vertices when loading polygons. + The cleaning process removes any polygons with zero area or <3 vertices. + Default `True`. + + Returns: + - Dict of pattern_name:Patterns generated from GDSII structures + - Dict of GDSII library info + """ + + lib = gdsii.library.Library.load(stream) + + library_info = {'name': lib.name.decode('ASCII'), + 'meters_per_unit': lib.physical_unit, + 'logical_units_per_unit': lib.logical_unit, + } + + raw_mode = True # Whether to construct shapes in raw mode (less error checking) + + patterns = [] + for structure in lib: + pat = Pattern(name=structure.name.decode('ASCII')) + for element in structure: + # Switch based on element type: + if isinstance(element, gdsii.elements.Boundary): + poly = _boundary_to_polygon(element, raw_mode) + pat.shapes.append(poly) + + if isinstance(element, gdsii.elements.Path): + path = _gpath_to_mpath(element, raw_mode) + pat.shapes.append(path) + + elif isinstance(element, gdsii.elements.Text): + label = Label(offset=element.xy.astype(float), + layer=(element.layer, element.text_type), + string=element.string.decode('ASCII')) + pat.labels.append(label) + + elif isinstance(element, (gdsii.elements.SRef, gdsii.elements.ARef)): + pat.subpatterns.append(_ref_to_subpat(element)) + + if clean_vertices: + clean_pattern_vertices(pat) + patterns.append(pat) + + # Create a dict of {pattern.name: pattern, ...}, then fix up all subpattern.pattern entries + # according to the subpattern.identifier (which is deleted after use). + patterns_dict = dict(((p.name, p) for p in patterns)) + for p in patterns_dict.values(): + for sp in p.subpatterns: + sp.pattern = patterns_dict[sp.identifier[0].decode('ASCII')] + del sp.identifier + + return patterns_dict, library_info + + +def _mlayer2gds(mlayer: layer_t) -> Tuple[int, int]: + """ Helper to turn a layer tuple-or-int into a layer and datatype""" + if isinstance(mlayer, int): + layer = mlayer + data_type = 0 + elif isinstance(mlayer, tuple): + layer = mlayer[0] + if len(mlayer) > 1: + data_type = mlayer[1] + else: + data_type = 0 + else: + raise PatternError(f'Invalid layer for gdsii: {mlayer}. Note that gdsii layers cannot be strings.') + return layer, data_type + + +def _ref_to_subpat( + element: Union[gdsii.elements.SRef, + gdsii.elements.ARef] + ) -> SubPattern: + """ + Helper function to create a SubPattern from an SREF or AREF. Sets subpat.pattern to None + and sets the instance .identifier to (struct_name,). + + NOTE: "Absolute" means not affected by parent elements. + That's not currently supported by masque at all (and not planned). + """ + rotation = 0.0 + offset = numpy.array(element.xy[0], dtype=float) + scale = 1.0 + mirror_across_x = False + repetition = None + + if element.strans is not None: + if element.mag is not None: + scale = element.mag + # Bit 13 means absolute scale + if get_bit(element.strans, 15 - 13): + raise PatternError('Absolute scale is not implemented in masque!') + if element.angle is not None: + rotation = numpy.deg2rad(element.angle) + # Bit 14 means absolute rotation + if get_bit(element.strans, 15 - 14): + raise PatternError('Absolute rotation is not implemented in masque!') + # Bit 0 means mirror x-axis + if get_bit(element.strans, 15 - 0): + mirror_across_x = True + + if isinstance(element, gdsii.elements.ARef): + a_count = element.cols + b_count = element.rows + a_vector = (element.xy[1] - offset) / a_count + b_vector = (element.xy[2] - offset) / b_count + repetition = Grid(a_vector=a_vector, b_vector=b_vector, + a_count=a_count, b_count=b_count) + + subpat = SubPattern(pattern=None, + offset=offset, + rotation=rotation, + scale=scale, + mirrored=(mirror_across_x, False), + annotations=_properties_to_annotations(element.properties), + repetition=repetition) + subpat.identifier = (element.struct_name,) + return subpat + + +def _gpath_to_mpath(element: gdsii.elements.Path, raw_mode: bool) -> Path: + if element.path_type in path_cap_map: + cap = path_cap_map[element.path_type] + else: + raise PatternError(f'Unrecognized path type: {element.path_type}') + + args = {'vertices': element.xy.astype(float), + 'layer': (element.layer, element.data_type), + 'width': element.width if element.width is not None else 0.0, + 'cap': cap, + 'offset': numpy.zeros(2), + 'annotations': _properties_to_annotations(element.properties), + 'raw': raw_mode, + } + + if cap == Path.Cap.SquareCustom: + args['cap_extensions'] = numpy.zeros(2) + if element.bgn_extn is not None: + args['cap_extensions'][0] = element.bgn_extn + if element.end_extn is not None: + args['cap_extensions'][1] = element.end_extn + + return Path(**args) + + +def _boundary_to_polygon(element: gdsii.elements.Boundary, raw_mode: bool) -> Polygon: + args = {'vertices': element.xy[:-1].astype(float), + 'layer': (element.layer, element.data_type), + 'offset': numpy.zeros(2), + 'annotations': _properties_to_annotations(element.properties), + 'raw': raw_mode, + } + return Polygon(**args) + + +def _subpatterns_to_refs( + subpatterns: List[SubPattern], + ) -> List[Union[gdsii.elements.ARef, gdsii.elements.SRef]]: + refs = [] + for subpat in subpatterns: + if subpat.pattern is None: + continue + encoded_name = subpat.pattern.name.encode('ASCII') + + # Note: GDS mirrors first and rotates second + mirror_across_x, extra_angle = normalize_mirror(subpat.mirrored) + rep = subpat.repetition + + new_refs: List[Union[gdsii.elements.SRef, gdsii.elements.ARef]] + ref: Union[gdsii.elements.SRef, gdsii.elements.ARef] + if isinstance(rep, Grid): + b_vector = rep.b_vector if rep.b_vector is not None else numpy.zeros(2) + b_count = rep.b_count if rep.b_count is not None else 1 + xy: NDArray[numpy.float64] = numpy.array(subpat.offset) + [ + [0, 0], + rep.a_vector * rep.a_count, + b_vector * b_count, + ] + ref = gdsii.elements.ARef( + struct_name=encoded_name, + xy=rint_cast(xy), + cols=rint_cast(rep.a_count), + rows=rint_cast(rep.b_count), + ) + new_refs = [ref] + elif rep is None: + ref = gdsii.elements.SRef( + struct_name=encoded_name, + xy=rint_cast([subpat.offset]), + ) + new_refs = [ref] + else: + new_refs = [gdsii.elements.SRef( + struct_name=encoded_name, + xy=rint_cast([subpat.offset + dd]), + ) + for dd in rep.displacements] + + for ref in new_refs: + ref.angle = numpy.rad2deg(subpat.rotation + extra_angle) % 360 + # strans must be non-None for angle and mag to take effect + ref.strans = set_bit(0, 15 - 0, mirror_across_x) + ref.mag = subpat.scale + ref.properties = _annotations_to_properties(subpat.annotations, 512) + + refs += new_refs + return refs + + +def _properties_to_annotations(properties: List[Tuple[int, bytes]]) -> annotations_t: + return {str(k): [v.decode()] for k, v in properties} + + +def _annotations_to_properties(annotations: annotations_t, max_len: int = 126) -> List[Tuple[int, bytes]]: + cum_len = 0 + props = [] + for key, vals in annotations.items(): + try: + i = int(key) + except ValueError: + raise PatternError(f'Annotation key {key} is not convertable to an integer') + if not (0 < i < 126): + raise PatternError(f'Annotation key {key} converts to {i} (must be in the range [1,125])') + + val_strings = ' '.join(str(val) for val in vals) + b = val_strings.encode() + if len(b) > 126: + raise PatternError(f'Annotation value {b!r} is longer than 126 characters!') + cum_len += numpy.ceil(len(b) / 2) * 2 + 2 + if cum_len > max_len: + raise PatternError(f'Sum of annotation data will be longer than {max_len} bytes! Generated bytes were {b!r}') + props.append((i, b)) + return props + + +def _shapes_to_elements( + shapes: List[Shape], + polygonize_paths: bool = False, + ) -> List[Union[gdsii.elements.Boundary, gdsii.elements.Path]]: + elements: List[Union[gdsii.elements.Boundary, gdsii.elements.Path]] = [] + # Add a Boundary element for each shape, and Path elements if necessary + for shape in shapes: + layer, data_type = _mlayer2gds(shape.layer) + properties = _annotations_to_properties(shape.annotations, 128) + if isinstance(shape, Path) and not polygonize_paths: + xy = rint_cast(shape.vertices + shape.offset) + width = rint_cast(shape.width) + path_type = next(k for k, v in path_cap_map.items() if v == shape.cap) # reverse lookup + path = gdsii.elements.Path(layer=layer, + data_type=data_type, + xy=xy) + path.path_type = path_type + path.width = width + path.properties = properties + elements.append(path) + else: + for polygon in shape.to_polygons(): + xy_closed = numpy.empty((polygon.vertices.shape[0] + 1, 2), dtype=numpy.int32) + numpy.rint(polygon.vertices + polygon.offset, out=xy_closed[:-1], casting='unsafe') + xy_closed[-1] = xy_closed[0] + boundary = gdsii.elements.Boundary( + layer=layer, + data_type=data_type, + xy=xy_closed, + ) + boundary.properties = properties + elements.append(boundary) + return elements + + +def _labels_to_texts(labels: List[Label]) -> List[gdsii.elements.Text]: + texts = [] + for label in labels: + properties = _annotations_to_properties(label.annotations, 128) + layer, text_type = _mlayer2gds(label.layer) + xy = rint_cast([label.offset]) + text = gdsii.elements.Text( + layer=layer, + text_type=text_type, + xy=xy, + string=label.string.encode('ASCII'), + ) + text.properties = properties + texts.append(text) + return texts + + +def disambiguate_pattern_names( + patterns: Sequence[Pattern], + max_name_length: int = 32, + suffix_length: int = 6, + dup_warn_filter: Optional[Callable[[str], bool]] = None, + ) -> None: + """ + Args: + patterns: List of patterns to disambiguate + max_name_length: Names longer than this will be truncated + suffix_length: Names which get truncated are truncated by this many extra characters. This is to + leave room for a suffix if one is necessary. + dup_warn_filter: (optional) Function for suppressing warnings about cell names changing. Receives + the cell name and returns `False` if the warning should be suppressed and `True` if it should + be displayed. Default displays all warnings. + """ + used_names = [] + for pat in set(patterns): + # Shorten names which already exceed max-length + if len(pat.name) > max_name_length: + shortened_name = pat.name[:max_name_length - suffix_length] + logger.warning(f'Pattern name "{pat.name}" is too long ({len(pat.name)}/{max_name_length} chars),\n' + + f' shortening to "{shortened_name}" before generating suffix') + else: + shortened_name = pat.name + + # Remove invalid characters + sanitized_name = re.compile(r'[^A-Za-z0-9_\?\$]').sub('_', shortened_name) + + # Add a suffix that makes the name unique + i = 0 + suffixed_name = sanitized_name + while suffixed_name in used_names or suffixed_name == '': + suffix = base64.b64encode(struct.pack('>Q', i), b'$?').decode('ASCII') + + suffixed_name = sanitized_name + '$' + suffix[:-1].lstrip('A') + i += 1 + + if sanitized_name == '': + logger.warning(f'Empty pattern name saved as "{suffixed_name}"') + elif suffixed_name != sanitized_name: + if dup_warn_filter is None or dup_warn_filter(pat.name): + logger.warning(f'Pattern name "{pat.name}" ({sanitized_name}) appears multiple times;\n' + + f' renaming to "{suffixed_name}"') + + # Encode into a byte-string and perform some final checks + encoded_name = suffixed_name.encode('ASCII') + if len(encoded_name) == 0: + # Should never happen since zero-length names are replaced + raise PatternError(f'Zero-length name after sanitize+encode,\n originally "{pat.name}"') + if len(encoded_name) > max_name_length: + raise PatternError(f'Pattern name "{encoded_name!r}" length > {max_name_length} after encode,\n' + + f' originally "{pat.name}"') + + pat.name = suffixed_name + used_names.append(suffixed_name) diff --git a/masque/file/svg.py b/masque/file/svg.py index 859c074..58c9c6a 100644 --- a/masque/file/svg.py +++ b/masque/file/svg.py @@ -1,8 +1,8 @@ """ SVG file format readers and writers """ -from collections.abc import Mapping -import logging +from typing import Dict, Optional +import warnings import numpy from numpy.typing import ArrayLike @@ -12,27 +12,23 @@ from .utils import mangle_name from .. import Pattern -logger = logging.getLogger(__name__) - - def writefile( - library: Mapping[str, Pattern], - top: str, + pattern: Pattern, filename: str, custom_attributes: bool = False, ) -> None: """ Write a Pattern to an SVG file, by first calling .polygonize() on it to change the shapes into polygons, and then writing patterns as SVG - groups (, inside ), polygons as paths (), and refs + groups (, inside ), polygons as paths (), and subpatterns as elements. Note that this function modifies the Pattern. - If `custom_attributes` is `True`, a non-standard `pattern_layer` attribute - is written to the relevant elements. + If `custom_attributes` is `True`, non-standard `pattern_layer` and `pattern_dose` attributes + are written to the relevant elements. - It is often a good idea to run `pattern.dedup()` on pattern prior to + It is often a good idea to run `pattern.subpatternize()` on pattern prior to calling this function, especially if calling `.polygonize()` will result in very many vertices. @@ -42,18 +38,17 @@ def writefile( Args: pattern: Pattern to write to file. Modified by this function. filename: Filename to write to. - custom_attributes: Whether to write non-standard `pattern_layer` attribute to the - SVG elements. + custom_attributes: Whether to write non-standard `pattern_layer` and + `pattern_dose` attributes to the SVG elements. """ - pattern = library[top] # Polygonize pattern pattern.polygonize() - bounds = pattern.get_bounds(library=library) + bounds = pattern.get_bounds() if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) - logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) + warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox') else: bounds_min, bounds_max = bounds @@ -64,39 +59,42 @@ def writefile( svg = svgwrite.Drawing(filename, profile='full', viewBox=viewbox_string, debug=(not custom_attributes)) - # Now create a group for each pattern and add in any Boundary and Use elements - for name, pat in library.items(): - svg_group = svg.g(id=mangle_name(name), fill='blue', stroke='red') + # Get a dict of id(pattern) -> pattern + patterns_by_id = {**(pattern.referenced_patterns_by_id()), id(pattern): pattern} # type: Dict[int, Optional[Pattern]] - for layer, shapes in pat.shapes.items(): - for shape in shapes: - for polygon in shape.to_polygons(): - path_spec = poly2path(polygon.vertices + polygon.offset) + # Now create a group for each row in sd_table (ie, each pattern + dose combination) + # and add in any Boundary and Use elements + for pat in patterns_by_id.values(): + if pat is None: + continue + svg_group = svg.g(id=mangle_name(pat), fill='blue', stroke='red') - path = svg.path(d=path_spec) - if custom_attributes: - path['pattern_layer'] = layer + for shape in pat.shapes: + for polygon in shape.to_polygons(): + path_spec = poly2path(polygon.vertices + polygon.offset) - svg_group.add(path) + path = svg.path(d=path_spec) + if custom_attributes: + path['pattern_layer'] = polygon.layer + path['pattern_dose'] = polygon.dose - for target, refs in pat.refs.items(): - if target is None: + svg_group.add(path) + + for subpat in pat.subpatterns: + if subpat.pattern is None: continue - for ref in refs: - transform = f'scale({ref.scale:g}) rotate({ref.rotation:g}) translate({ref.offset[0]:g},{ref.offset[1]:g})' - use = svg.use(href='#' + mangle_name(target), transform=transform) - svg_group.add(use) + transform = f'scale({subpat.scale:g}) rotate({subpat.rotation:g}) translate({subpat.offset[0]:g},{subpat.offset[1]:g})' + use = svg.use(href='#' + mangle_name(subpat.pattern), transform=transform) + if custom_attributes: + use['pattern_dose'] = subpat.dose + svg_group.add(use) svg.defs.add(svg_group) - svg.add(svg.use(href='#' + mangle_name(top))) + svg.add(svg.use(href='#' + mangle_name(pattern))) svg.save() -def writefile_inverted( - library: Mapping[str, Pattern], - top: str, - filename: str, - ) -> None: +def writefile_inverted(pattern: Pattern, filename: str): """ Write an inverted Pattern to an SVG file, by first calling `.polygonize()` and `.flatten()` on it to change the shapes into polygons, then drawing a bounding @@ -112,15 +110,13 @@ def writefile_inverted( pattern: Pattern to write to file. Modified by this function. filename: Filename to write to. """ - pattern = library[top] - # Polygonize and flatten pattern - pattern.polygonize().flatten(library) + pattern.polygonize().flatten() - bounds = pattern.get_bounds(library=library) + bounds = pattern.get_bounds() if bounds is None: bounds_min, bounds_max = numpy.array([[-1, -1], [1, 1]]) - logger.warning('Pattern had no bounds (empty?); setting arbitrary viewbox', stacklevel=1) + warnings.warn('Pattern had no bounds (empty?); setting arbitrary viewbox') else: bounds_min, bounds_max = bounds @@ -138,10 +134,9 @@ def writefile_inverted( path_spec = poly2path(slab_edge) # Draw polygons with reversed vertex order - for _layer, shapes in pattern.shapes.items(): - for shape in shapes: - for polygon in shape.to_polygons(): - path_spec += poly2path(polygon.vertices[::-1] + polygon.offset) + for shape in pattern.shapes: + for polygon in shape.to_polygons(): + path_spec += poly2path(polygon.vertices[::-1] + polygon.offset) svg.add(svg.path(d=path_spec, fill='blue', stroke='red')) svg.save() @@ -157,9 +152,9 @@ def poly2path(vertices: ArrayLike) -> str: Returns: SVG path-string. """ - verts = numpy.asarray(vertices) - commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1]) # noqa: UP032 + verts = numpy.array(vertices, copy=False) + commands = 'M{:g},{:g} '.format(verts[0][0], verts[0][1]) for vertex in verts[1:]: - commands += 'L{:g},{:g}'.format(vertex[0], vertex[1]) # noqa: UP032 + commands += 'L{:g},{:g}'.format(vertex[0], vertex[1]) commands += ' Z ' return commands diff --git a/masque/file/utils.py b/masque/file/utils.py index 33f68d4..47e8b7d 100644 --- a/masque/file/utils.py +++ b/masque/file/utils.py @@ -1,105 +1,29 @@ """ Helper functions for file reading and writing """ -from typing import IO -from collections.abc import Iterator, Mapping +from typing import Set, Tuple, List import re +import copy import pathlib -import logging -import tempfile -import shutil -from collections import defaultdict -from contextlib import contextmanager -from pprint import pformat -from itertools import chain -from .. import Pattern, PatternError, Library, LibraryError +from .. import Pattern, PatternError from ..shapes import Polygon, Path -logger = logging.getLogger(__name__) - - -def preflight( - lib: Library, - sort: bool = True, - sort_elements: bool = False, - allow_dangling_refs: bool | None = None, - allow_named_layers: bool = True, - prune_empty_patterns: bool = False, - wrap_repeated_shapes: bool = False, - ) -> Library: +def mangle_name(pattern: Pattern, dose_multiplier: float = 1.0) -> str: """ - Run a standard set of useful operations and checks, usually done immediately prior - to writing to a file (or immediately after reading). + Create a name using `pattern.name`, `id(pattern)`, and the dose multiplier. Args: - sort: Whether to sort the patterns based on their names, and optionaly sort the pattern contents. - Default True. Useful for reproducible builds. - sort_elements: Whether to sort the pattern contents. Requires sort=True to run. - allow_dangling_refs: If `None` (default), warns about any refs to patterns that are not - in the provided library. If `True`, no check is performed; if `False`, a `LibraryError` - is raised instead. - allow_named_layers: If `False`, raises a `PatternError` if any layer is referred to by - a string instead of a number (or tuple). - prune_empty_patterns: Runs `Library.prune_empty()`, recursively deleting any empty patterns. - wrap_repeated_shapes: Runs `Library.wrap_repeated_shapes()`, turning repeated shapes into - repeated refs containing non-repeated shapes. - - Returns: - `lib` or an equivalent sorted library - """ - if sort: - lib = Library(dict(sorted( - (nn, pp.sort(sort_elements=sort_elements)) for nn, pp in lib.items() - ))) - - if not allow_dangling_refs: - refs = lib.referenced_patterns() - dangling = refs - set(lib.keys()) - if dangling: - msg = 'Dangling refs found: ' + pformat(dangling) - if allow_dangling_refs is None: - logger.warning(msg) - else: - raise LibraryError(msg) - - if not allow_named_layers: - named_layers: Mapping[str, set] = defaultdict(set) - for name, pat in lib.items(): - for layer in chain(pat.shapes.keys(), pat.labels.keys()): - if isinstance(layer, str): - named_layers[name].add(layer) - named_layers = dict(named_layers) - if named_layers: - raise PatternError('Non-numeric layers found:' + pformat(named_layers)) - - if prune_empty_patterns: - pruned = lib.prune_empty() - if pruned: - logger.info(f'Preflight pruned {len(pruned)} empty patterns') - logger.debug('Pruned: ' + pformat(pruned)) - else: - logger.debug('Preflight found no empty patterns') - - if wrap_repeated_shapes: - lib.wrap_repeated_shapes() - - return lib - - -def mangle_name(name: str) -> str: - """ - Sanitize a name. - - Args: - name: Name we want to mangle. + pattern: Pattern whose name we want to mangle. + dose_multiplier: Dose multiplier to mangle with. Returns: Mangled name. """ expression = re.compile(r'[^A-Za-z0-9_\?\$]') - sanitized_name = expression.sub('_', name) + full_name = '{}_{}_{}'.format(pattern.name, dose_multiplier, id(pattern)) + sanitized_name = expression.sub('_', full_name) return sanitized_name @@ -114,39 +38,149 @@ def clean_pattern_vertices(pat: Pattern) -> Pattern: Returns: pat """ - for shapes in pat.shapes.values(): - remove_inds = [] - for ii, shape in enumerate(shapes): - if not isinstance(shape, Polygon | Path): - continue - try: - shape.clean_vertices() - except PatternError: - remove_inds.append(ii) - for ii in sorted(remove_inds, reverse=True): - del shapes[ii] + remove_inds = [] + for ii, shape in enumerate(pat.shapes): + if not isinstance(shape, (Polygon, Path)): + continue + try: + shape.clean_vertices() + except PatternError: + remove_inds.append(ii) + for ii in sorted(remove_inds, reverse=True): + del pat.shapes[ii] return pat +def make_dose_table(patterns: List[Pattern], dose_multiplier: float = 1.0) -> Set[Tuple[int, float]]: + """ + Create a set containing `(id(pat), written_dose)` for each pattern (including subpatterns) + + Args: + pattern: Source Patterns. + dose_multiplier: Multiplier for all written_dose entries. + + Returns: + `{(id(subpat.pattern), written_dose), ...}` + """ + dose_table = {(id(pattern), dose_multiplier) for pattern in patterns} + for pattern in patterns: + for subpat in pattern.subpatterns: + if subpat.pattern is None: + continue + subpat_dose_entry = (id(subpat.pattern), subpat.dose * dose_multiplier) + if subpat_dose_entry not in dose_table: + subpat_dose_table = make_dose_table([subpat.pattern], subpat.dose * dose_multiplier) + dose_table = dose_table.union(subpat_dose_table) + return dose_table + + +def dtype2dose(pattern: Pattern) -> Pattern: + """ + For each shape in the pattern, if the layer is a tuple, set the + layer to the tuple's first element and set the dose to the + tuple's second element. + + Generally intended for use with `Pattern.apply()`. + + Args: + pattern: Pattern to modify + + Returns: + pattern + """ + for shape in pattern.shapes: + if isinstance(shape.layer, tuple): + shape.dose = shape.layer[1] + shape.layer = shape.layer[0] + return pattern + + +def dose2dtype( + patterns: List[Pattern], + ) -> Tuple[List[Pattern], List[float]]: + """ + For each shape in each pattern, set shape.layer to the tuple + (base_layer, datatype), where: + layer is chosen to be equal to the original shape.layer if it is an int, + or shape.layer[0] if it is a tuple. `str` layers raise a PatterError. + datatype is chosen arbitrarily, based on calcualted dose for each shape. + Shapes with equal calcualted dose will have the same datatype. + A list of doses is retured, providing a mapping between datatype + (list index) and dose (list entry). + + Note that this function modifies the input Pattern(s). + + Args: + patterns: A `Pattern` or list of patterns to write to file. Modified by this function. + + Returns: + (patterns, dose_list) + patterns: modified input patterns + dose_list: A list of doses, providing a mapping between datatype (int, list index) + and dose (float, list entry). + """ + # Get a dict of id(pattern) -> pattern + patterns_by_id = {id(pattern): pattern for pattern in patterns} + for pattern in patterns: + for i, p in pattern.referenced_patterns_by_id().items(): + patterns_by_id[i] = p + + # Get a table of (id(pat), written_dose) for each pattern and subpattern + sd_table = make_dose_table(patterns) + + # Figure out all the unique doses necessary to write this pattern + # This means going through each row in sd_table and adding the dose values needed to write + # that subpattern at that dose level + dose_vals = set() + for pat_id, pat_dose in sd_table: + pat = patterns_by_id[pat_id] + for shape in pat.shapes: + dose_vals.add(shape.dose * pat_dose) + + if len(dose_vals) > 256: + raise PatternError('Too many dose values: {}, maximum 256 when using dtypes.'.format(len(dose_vals))) + + dose_vals_list = list(dose_vals) + + # Create a new pattern for each non-1-dose entry in the dose table + # and update the shapes to reflect their new dose + new_pats = {} # (id, dose) -> new_pattern mapping + for pat_id, pat_dose in sd_table: + if pat_dose == 1: + new_pats[(pat_id, pat_dose)] = patterns_by_id[pat_id] + continue + + old_pat = patterns_by_id[pat_id] + pat = old_pat.copy() # keep old subpatterns + pat.shapes = copy.deepcopy(old_pat.shapes) + pat.labels = copy.deepcopy(old_pat.labels) + + encoded_name = mangle_name(pat, pat_dose) + if len(encoded_name) == 0: + raise PatternError('Zero-length name after mangle+encode, originally "{}"'.format(pat.name)) + pat.name = encoded_name + + for shape in pat.shapes: + data_type = dose_vals_list.index(shape.dose * pat_dose) + if isinstance(shape.layer, int): + shape.layer = (shape.layer, data_type) + elif isinstance(shape.layer, tuple): + shape.layer = (shape.layer[0], data_type) + else: + raise PatternError(f'Invalid layer for gdsii: {shape.layer}') + + new_pats[(pat_id, pat_dose)] = pat + + # Go back through all the dose-specific patterns and fix up their subpattern entries + for (pat_id, pat_dose), pat in new_pats.items(): + for subpat in pat.subpatterns: + dose_mult = subpat.dose * pat_dose + subpat.pattern = new_pats[(id(subpat.pattern), dose_mult)] + + return patterns, dose_vals_list + + def is_gzipped(path: pathlib.Path) -> bool: - with path.open('rb') as stream: + with open(path, 'rb') as stream: magic_bytes = stream.read(2) return magic_bytes == b'\x1f\x8b' - - -@contextmanager -def tmpfile(path: str | pathlib.Path) -> Iterator[IO[bytes]]: - """ - Context manager which allows you to write to a temporary file, - and move that file into its final location only after the write - has finished. - """ - path = pathlib.Path(path) - suffixes = ''.join(path.suffixes) - with tempfile.NamedTemporaryFile(suffix=suffixes, delete=False) as tmp_stream: - yield tmp_stream - - try: - shutil.move(tmp_stream.name, path) - finally: - pathlib.Path(tmp_stream.name).unlink(missing_ok=True) diff --git a/masque/label.py b/masque/label.py index 711ef35..fe7d9ab 100644 --- a/masque/label.py +++ b/masque/label.py @@ -1,30 +1,31 @@ -from typing import Self, Any +from typing import Tuple, Dict, Optional, TypeVar import copy -import functools import numpy from numpy.typing import ArrayLike, NDArray from .repetition import Repetition -from .utils import rotation_matrix_2d, annotations_t, annotations_eq, annotations_lt, rep2key -from .traits import PositionableImpl, Copyable, Pivotable, RepeatableImpl, Bounded +from .utils import rotation_matrix_2d, layer_t, AutoSlots, annotations_t +from .traits import PositionableImpl, LayerableImpl, Copyable, Pivotable, LockableImpl, RepeatableImpl from .traits import AnnotatableImpl -@functools.total_ordering -class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotable, Copyable): +L = TypeVar('L', bound='Label') + + +class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl, AnnotatableImpl, + Pivotable, Copyable, metaclass=AutoSlots): """ - A text annotation with a position (but no size; it is not drawn) + A text annotation with a position and layer (but no size; it is not drawn) """ - __slots__ = ( - '_string', - # Inherited - '_offset', '_repetition', '_annotations', - ) + __slots__ = ( '_string', 'identifier') _string: str """ Label string """ + identifier: Tuple + """ Arbitrary identifier tuple, useful for keeping track of history when flattening """ + ''' ---- Properties ''' @@ -45,45 +46,38 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl string: str, *, offset: ArrayLike = (0.0, 0.0), - repetition: Repetition | None = None, - annotations: annotations_t | None = None, + layer: layer_t = 0, + repetition: Optional[Repetition] = None, + annotations: Optional[annotations_t] = None, + locked: bool = False, + identifier: Tuple = (), ) -> None: + LockableImpl.unlock(self) + self.identifier = identifier self.string = string - self.offset = numpy.array(offset, dtype=float) + self.offset = numpy.array(offset, dtype=float, copy=True) + self.layer = layer self.repetition = repetition self.annotations = annotations if annotations is not None else {} + self.set_locked(locked) - def __copy__(self) -> Self: - return type(self)( - string=self.string, - offset=self.offset.copy(), - repetition=self.repetition, - ) + def __copy__(self: L) -> L: + return type(self)(string=self.string, + offset=self.offset.copy(), + layer=self.layer, + repetition=self.repetition, + locked=self.locked, + identifier=self.identifier) - def __deepcopy__(self, memo: dict | None = None) -> Self: + def __deepcopy__(self: L, memo: Dict = None) -> L: memo = {} if memo is None else memo new = copy.copy(self) + LockableImpl.unlock(new) new._offset = self._offset.copy() + new.set_locked(self.locked) return new - def __lt__(self, other: 'Label') -> bool: - if self.string != other.string: - return self.string < other.string - if not numpy.array_equal(self.offset, other.offset): - return tuple(self.offset) < tuple(other.offset) - if self.repetition != other.repetition: - return rep2key(self.repetition) < rep2key(other.repetition) - return annotations_lt(self.annotations, other.annotations) - - def __eq__(self, other: Any) -> bool: - return ( - self.string == other.string - and numpy.array_equal(self.offset, other.offset) - and self.repetition == other.repetition - and annotations_eq(self.annotations, other.annotations) - ) - - def rotate_around(self, pivot: ArrayLike, rotation: float) -> Self: + def rotate_around(self: L, pivot: ArrayLike, rotation: float) -> L: """ Rotate the label around a point. @@ -94,13 +88,13 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl Returns: self """ - pivot = numpy.asarray(pivot, dtype=float) + pivot = numpy.array(pivot, dtype=float) self.translate(-pivot) self.offset = numpy.dot(rotation_matrix_2d(rotation), self.offset) self.translate(+pivot) return self - def get_bounds_single(self) -> NDArray[numpy.float64]: + def get_bounds(self) -> NDArray[numpy.float64]: """ Return the bounds of the label. @@ -112,3 +106,17 @@ class Label(PositionableImpl, RepeatableImpl, AnnotatableImpl, Bounded, Pivotabl Bounds [[xmin, xmax], [ymin, ymax]] """ return numpy.array([self.offset, self.offset]) + + def lock(self: L) -> L: + PositionableImpl._lock(self) + LockableImpl.lock(self) + return self + + def unlock(self: L) -> L: + LockableImpl.unlock(self) + PositionableImpl._unlock(self) + return self + + def __repr__(self) -> str: + locked = ' L' if self.locked else '' + return f'