From 2bc03cbbf4f09c9fc4deaf4f2323c9a62437d98a Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 1 Nov 2020 19:33:43 -0800 Subject: [PATCH 01/20] improve some more type annotations using TypeVar --- masque/file/klamath.py | 1 + masque/label.py | 17 ++++++---- masque/pattern.py | 72 ++++++++++++++++++++++-------------------- masque/subpattern.py | 19 ++++++----- 4 files changed, 60 insertions(+), 49 deletions(-) diff --git a/masque/file/klamath.py b/masque/file/klamath.py index 0f858cf..1c22e73 100644 --- a/masque/file/klamath.py +++ b/masque/file/klamath.py @@ -576,6 +576,7 @@ def load_library(stream: BinaryIO, if is_secondary is None: def is_secondary(k: str): return False + assert(is_secondary is not None) stream.seek(0) library_info = _read_header(stream) diff --git a/masque/label.py b/masque/label.py index 5027af5..b436653 100644 --- a/masque/label.py +++ b/masque/label.py @@ -1,4 +1,4 @@ -from typing import Tuple, Dict, Optional +from typing import Tuple, Dict, Optional, TypeVar import copy import numpy # type: ignore @@ -8,6 +8,9 @@ from .traits import PositionableImpl, LayerableImpl, Copyable, Pivotable, Lockab from .traits import AnnotatableImpl +L = TypeVar('L', bound='Label') + + class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl, AnnotatableImpl, Pivotable, Copyable, metaclass=AutoSlots): """ @@ -44,7 +47,7 @@ class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl, Annot repetition: Optional[Repetition] = None, annotations: Optional[annotations_t] = None, locked: bool = False, - ): + ) -> None: LockableImpl.unlock(self) self.identifier = () self.string = string @@ -54,21 +57,21 @@ class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl, Annot self.annotations = annotations if annotations is not None else {} self.set_locked(locked) - def __copy__(self) -> 'Label': + def __copy__(self: L) -> L: return Label(string=self.string, offset=self.offset.copy(), layer=self.layer, repetition=self.repetition, locked=self.locked) - def __deepcopy__(self, memo: Dict = None) -> 'Label': + def __deepcopy__(self: L, memo: Dict = None) -> L: memo = {} if memo is None else memo new = copy.copy(self).unlock() new._offset = self._offset.copy() new.set_locked(self.locked) return new - def rotate_around(self, pivot: vector2, rotation: float) -> 'Label': + def rotate_around(self: L, pivot: vector2, rotation: float) -> L: """ Rotate the label around a point. @@ -98,12 +101,12 @@ class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl, Annot """ return numpy.array([self.offset, self.offset]) - def lock(self) -> 'Label': + def lock(self: L) -> L: PositionableImpl._lock(self) LockableImpl.lock(self) return self - def unlock(self) -> 'Label': + def unlock(self: L) -> L: LockableImpl.unlock(self) PositionableImpl._unlock(self) return self diff --git a/masque/pattern.py b/masque/pattern.py index 33c5030..fd1b8de 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -3,7 +3,7 @@ """ from typing import List, Callable, Tuple, Dict, Union, Set, Sequence, Optional, Type, overload -from typing import MutableMapping, Iterable +from typing import MutableMapping, Iterable, TypeVar, Any import copy import pickle from itertools import chain @@ -24,6 +24,9 @@ from .traits import LockableImpl, AnnotatableImpl, Scalable visitor_function_t = Callable[['Pattern', Tuple['Pattern'], Dict, numpy.ndarray], 'Pattern'] +P = TypeVar('P', bound='Pattern') + + class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): """ 2D layout consisting of some set of shapes, labels, and references to other Pattern objects @@ -56,7 +59,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): subpatterns: Sequence[SubPattern] = (), annotations: Optional[annotations_t] = None, locked: bool = False, - ): + ) -> None: """ Basic init; arguments get assigned to member variables. Non-list inputs for shapes and subpatterns get converted to lists. @@ -112,11 +115,11 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): locked=self.locked) return new - def rename(self, name: str) -> 'Pattern': + def rename(self: P, name: str) -> P: self.name = name return self - def append(self, other_pattern: 'Pattern') -> 'Pattern': + def append(self: P, other_pattern: P) -> P: """ Appends all shapes, labels and subpatterns from other_pattern to self's shapes, labels, and supbatterns. @@ -220,13 +223,13 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): pat = memo[pat_id] return pat - def dfs(self, + def dfs(self: P, visit_before: visitor_function_t = None, visit_after: visitor_function_t = None, transform: Union[numpy.ndarray, bool, None] = False, memo: Optional[Dict] = None, - hierarchy: Tuple['Pattern', ...] = (), - ) -> 'Pattern': + hierarchy: Tuple[P, ...] = (), + ) -> P: """ Experimental convenience function. Performs a depth-first traversal of this pattern and its subpatterns. @@ -305,10 +308,10 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): pat = visit_after(pat, hierarchy=hierarchy, memo=memo, transform=transform) # type: ignore return pat - def polygonize(self, + def polygonize(self: P, poly_num_points: Optional[int] = None, poly_max_arclen: Optional[float] = None, - ) -> 'Pattern': + ) -> P: """ Calls `.to_polygons(...)` on all the shapes in this Pattern and any referenced patterns, replacing them with the returned polygons. @@ -333,10 +336,10 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): subpat.pattern.polygonize(poly_num_points, poly_max_arclen) return self - def manhattanize(self, + def manhattanize(self: P, grid_x: numpy.ndarray, grid_y: numpy.ndarray, - ) -> 'Pattern': + ) -> P: """ Calls `.polygonize()` and `.flatten()` on the pattern, then calls `.manhattanize()` on all the resulting shapes, replacing them with the returned Manhattan polygons. @@ -355,11 +358,11 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): (shape.manhattanize(grid_x, grid_y) for shape in old_shapes))) return self - def subpatternize(self, + def subpatternize(self: P, recursive: bool = True, norm_value: int = int(1e6), exclude_types: Tuple[Type] = (Polygon,) - ) -> 'Pattern': + ) -> P: """ Iterates through this `Pattern` and all referenced `Pattern`s. Within each `Pattern`, it iterates over all shapes, calling `.normalized_form(norm_value)` on them to retrieve a scale-, @@ -476,7 +479,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): ids.update(pat.referenced_patterns_by_id()) return ids - def referenced_patterns_by_name(self, **kwargs) -> List[Tuple[Optional[str], Optional['Pattern']]]: + def referenced_patterns_by_name(self, **kwargs: Any) -> List[Tuple[Optional[str], Optional['Pattern']]]: """ Create a list of `(pat.name, pat)` tuples for all Pattern objects referenced by this Pattern (operates recursively on all referenced Patterns as well). @@ -544,7 +547,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): else: return numpy.vstack((min_bounds, max_bounds)) - def flatten(self) -> 'Pattern': + def flatten(self: P) -> P: """ Removes all subpatterns and adds equivalent shapes. @@ -580,10 +583,10 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): self.append(p) return self - def wrap_repeated_shapes(self, + def wrap_repeated_shapes(self: P, name_func: Callable[['Pattern', Union[Shape, Label]], str] = lambda p, s: '_repetition', recursive: bool = True, - ) -> 'Pattern': + ) -> P: """ Wraps all shapes and labels with a non-`None` `repetition` attribute into a `SubPattern`/`Pattern` combination, and applies the `repetition` @@ -625,7 +628,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): return self - def translate_elements(self, offset: vector2) -> 'Pattern': + def translate_elements(self: P, offset: vector2) -> P: """ Translates all shapes, label, and subpatterns by the given offset. @@ -639,7 +642,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): entry.translate(offset) return self - def scale_elements(self, c: float) -> 'Pattern': + def scale_elements(self: P, c: float) -> P: """" Scales all shapes and subpatterns by the given value. @@ -653,7 +656,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): entry.scale_by(c) return self - def scale_by(self, c: float) -> 'Pattern': + def scale_by(self: P, c: float) -> P: """ Scale this Pattern by the given value (all shapes and subpatterns and their offsets are scaled) @@ -672,7 +675,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): label.offset *= c return self - def rotate_around(self, pivot: vector2, rotation: float) -> 'Pattern': + def rotate_around(self: P, pivot: vector2, rotation: float) -> P: """ Rotate the Pattern around the a location. @@ -690,7 +693,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): self.translate_elements(+pivot) return self - def rotate_element_centers(self, rotation: float) -> 'Pattern': + def rotate_element_centers(self: P, rotation: float) -> P: """ Rotate the offsets of all shapes, labels, and subpatterns around (0, 0) @@ -704,7 +707,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): entry.offset = numpy.dot(rotation_matrix_2d(rotation), entry.offset) return self - def rotate_elements(self, rotation: float) -> 'Pattern': + def rotate_elements(self: P, rotation: float) -> P: """ Rotate each shape and subpattern around its center (offset) @@ -718,7 +721,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): entry.rotate(rotation) return self - def mirror_element_centers(self, axis: int) -> 'Pattern': + def mirror_element_centers(self: P, axis: int) -> P: """ Mirror the offsets of all shapes, labels, and subpatterns across an axis @@ -733,7 +736,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): entry.offset[axis - 1] *= -1 return self - def mirror_elements(self, axis: int) -> 'Pattern': + def mirror_elements(self: P, axis: int) -> P: """ Mirror each shape and subpattern across an axis, relative to its offset @@ -749,7 +752,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): entry.mirror(axis) return self - def mirror(self, axis: int) -> 'Pattern': + def mirror(self: P, axis: int) -> P: """ Mirror the Pattern across an axis @@ -764,7 +767,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): self.mirror_element_centers(axis) return self - def scale_element_doses(self, c: float) -> 'Pattern': + def scale_element_doses(self: P, c: float) -> P: """ Multiply all shape and subpattern doses by a factor @@ -778,7 +781,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): entry.dose *= c return self - def copy(self) -> 'Pattern': + def copy(self: P) -> P: """ Return a copy of the Pattern, deep-copying shapes and copying subpattern entries, but not deep-copying any referenced patterns. @@ -790,7 +793,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): """ return copy.copy(self) - def deepcopy(self) -> 'Pattern': + def deepcopy(self: P) -> P: """ Convenience method for `copy.deepcopy(pattern)` @@ -808,7 +811,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): and len(self.shapes) == 0 and len(self.labels) == 0) - def lock(self) -> 'Pattern': + def lock(self: P) -> P: """ Lock the pattern, raising an exception if it is modified. Also see `deeplock()`. @@ -823,7 +826,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): LockableImpl.lock(self) return self - def unlock(self) -> 'Pattern': + def unlock(self: P) -> P: """ Unlock the pattern @@ -837,7 +840,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): self.subpatterns = list(self.subpatterns) return self - def deeplock(self) -> 'Pattern': + def deeplock(self: P) -> P: """ Recursively lock the pattern, all referenced shapes, subpatterns, and labels. @@ -851,7 +854,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): sp.deeplock() return self - def deepunlock(self) -> 'Pattern': + def deepunlock(self: P) -> P: """ Recursively unlock the pattern, all referenced shapes, subpatterns, and labels. @@ -902,7 +905,8 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): offset: vector2 = (0., 0.), line_color: str = 'k', fill_color: str = 'none', - overdraw: bool = False): + overdraw: bool = False, + ) -> None: """ Draw a picture of the Pattern and wait for the user to inspect it diff --git a/masque/subpattern.py b/masque/subpattern.py index 8eebc95..89e143e 100644 --- a/masque/subpattern.py +++ b/masque/subpattern.py @@ -4,7 +4,7 @@ """ #TODO more top-level documentation -from typing import Dict, Tuple, Optional, Sequence, TYPE_CHECKING, Any +from typing import Dict, Tuple, Optional, Sequence, TYPE_CHECKING, Any, TypeVar import copy import numpy # type: ignore @@ -22,6 +22,9 @@ if TYPE_CHECKING: from . import Pattern +S = TypeVar('S', bound='SubPattern') + + class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mirrorable, PivotableImpl, Copyable, RepeatableImpl, LockableImpl, AnnotatableImpl, metaclass=AutoSlots): @@ -55,7 +58,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi annotations: Optional[annotations_t] = None, locked: bool = False, identifier: Tuple[Any, ...] = (), - ): + ) -> None: """ Args: pattern: Pattern to reference. @@ -150,13 +153,13 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi return pattern - def rotate(self, rotation: float) -> 'SubPattern': + def rotate(self: S, rotation: float) -> S: self.rotation += rotation if self.repetition is not None: self.repetition.rotate(rotation) return self - def mirror(self, axis: int) -> 'SubPattern': + def mirror(self: S, axis: int) -> S: self.mirrored[axis] = not self.mirrored[axis] self.rotation *= -1 if self.repetition is not None: @@ -176,7 +179,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi return None return self.as_pattern().get_bounds() - def lock(self) -> 'SubPattern': + def lock(self: S) -> S: """ Lock the SubPattern, disallowing changes @@ -188,7 +191,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi LockableImpl.lock(self) return self - def unlock(self) -> 'SubPattern': + def unlock(self: S) -> S: """ Unlock the SubPattern @@ -200,7 +203,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi self.mirrored.flags.writeable = True return self - def deeplock(self) -> 'SubPattern': + def deeplock(self: S) -> S: """ Recursively lock the SubPattern and its contained pattern @@ -212,7 +215,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi self.pattern.deeplock() return self - def deepunlock(self) -> 'SubPattern': + def deepunlock(self: S) -> S: """ Recursively unlock the SubPattern and its contained pattern From 517a6dec980a8d19c5a5347e9ca174773742f540 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 1 Nov 2020 19:34:25 -0800 Subject: [PATCH 02/20] remove duplicate setattr definition already set by LockableImpl --- masque/pattern.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index fd1b8de..ff3b8b5 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -91,11 +91,6 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): self.name = name self.set_locked(locked) - def __setattr__(self, name, value): - if self.locked and name != 'locked': - raise PatternLockedError() - object.__setattr__(self, name, value) - def __copy__(self, memo: Dict = None) -> 'Pattern': return Pattern(name=self.name, shapes=copy.deepcopy(self.shapes), From 3a9eca91b5138440429c4afac70a4b477f9a3497 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 1 Nov 2020 19:34:47 -0800 Subject: [PATCH 03/20] add `addsp()` convenience function to Pattern --- masque/pattern.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/masque/pattern.py b/masque/pattern.py index ff3b8b5..bad19ac 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -806,6 +806,21 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): and len(self.shapes) == 0 and len(self.labels) == 0) + def addsp(self: P, *args: Any, **kwargs: Any) -> P: + """ + Convenience function which constructs a subpattern object and adds it + to this pattern. + + Args: + *args: Passed to `SubPattern()` + **kwargs: Passed to `SubPattern()` + + Returns: + self + """ + self.subpatterns.append(SubPattern(*args, **kwargs)) + return self + def lock(self: P) -> P: """ Lock the pattern, raising an exception if it is modified. From 1b400605b112f1a177deec694e8ceb6887340909 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 1 Nov 2020 19:35:12 -0800 Subject: [PATCH 04/20] use addsp() to consolidate some existing code --- masque/pattern.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/masque/pattern.py b/masque/pattern.py index bad19ac..2dcd9c6 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -413,9 +413,8 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): for i, values in shape_table[label][1]: (offset, scale, rotation, mirror_x, dose) = values - subpat = SubPattern(pattern=pat, offset=offset, scale=scale, - rotation=rotation, dose=dose, mirrored=(mirror_x, False)) - self.subpatterns.append(subpat) + self.addsp(pattern=pat, offset=offset, scale=scale, + rotation=rotation, dose=dose, mirrored=(mirror_x, False)) shapes_to_remove.append(i) # Remove any shapes for which we have created subpatterns. @@ -600,20 +599,18 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): if pat is None: return pat - new_subpatterns = [] for shape in pat.shapes: if shape.repetition is None: continue - new_subpatterns.append(SubPattern(Pattern(name_func(pat, shape), shapes=[shape]))) + pat.addsp(Pattern(name_func(pat, shape), shapes=[shape])) shape.repetition = None for label in self.labels: if label.repetition is None: continue - new_subpatterns.append(SubPattern(Pattern(name_func(pat, shape), labels=[label]))) + pat.addsp(Pattern(name_func(pat, shape), labels=[label])) label.repetition = None - pat.subpatterns += new_subpatterns return pat if recursive: From b9322be77fa2aeeb5270c075c494d938108887a6 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 1 Nov 2020 19:45:31 -0800 Subject: [PATCH 05/20] move VERSION into a .py file that gets directly parsed by setup.py avoids possible issues with pyinstaller --- masque/VERSION | 1 - masque/VERSION.py | 4 ++++ masque/__init__.py | 7 ++----- setup.py | 7 +++---- 4 files changed, 9 insertions(+), 10 deletions(-) delete mode 100644 masque/VERSION create mode 100644 masque/VERSION.py diff --git a/masque/VERSION b/masque/VERSION deleted file mode 100644 index 8bbe6cf..0000000 --- a/masque/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.2 diff --git a/masque/VERSION.py b/masque/VERSION.py new file mode 100644 index 0000000..c80f65a --- /dev/null +++ b/masque/VERSION.py @@ -0,0 +1,4 @@ +""" VERSION defintion. THIS FILE IS MANUALLY PARSED BY setup.py and REQUIRES A SPECIFIC FORMAT """ +__version__ = ''' +2.2 +''' diff --git a/masque/__init__.py b/masque/__init__.py index 87ceb5d..48059b3 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -17,8 +17,6 @@ otherwise noted, assume that arguments are stored by-reference. """ -import pathlib - from .error import PatternError, PatternLockedError from .shapes import Shape from .label import Label @@ -30,6 +28,5 @@ from .library import Library __author__ = 'Jan Petykiewicz' -with open(pathlib.Path(__file__).parent / 'VERSION', 'r') as f: - __version__ = f.read().strip() -version = __version__ +from .VERSION import __version__ +version = __version__ # legacy diff --git a/setup.py b/setup.py index ff8aad9..f12de6a 100644 --- a/setup.py +++ b/setup.py @@ -6,8 +6,8 @@ from setuptools import setup, find_packages with open('README.md', 'r') as f: long_description = f.read() -with open('masque/VERSION', 'r') as f: - version = f.read().strip() +with open('masque/VERSION.py', 'rt') as f: + version = f.readlines()[2].strip() setup(name='masque', version=version, @@ -19,8 +19,7 @@ setup(name='masque', url='https://mpxd.net/code/jan/masque', packages=find_packages(), package_data={ - 'masque': ['VERSION', - 'py.typed', + 'masque': ['py.typed', ] }, install_requires=[ From 12f158ad3cf6597c23c086beb931a9df761cfe64 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 1 Nov 2020 19:45:47 -0800 Subject: [PATCH 06/20] require klamath > 0.3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index f12de6a..5dd3085 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ setup(name='masque', ], extras_require={ 'gdsii': ['python-gdsii'], - 'klamath': ['klamath'], + 'klamath': ['klamath>=0.3'], 'oasis': ['fatamorgana>=0.7'], 'dxf': ['ezdxf'], 'svg': ['svgwrite'], From ccb42e220f92846a408a24c6eaeb11504ef3e00b Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 1 Nov 2020 19:45:57 -0800 Subject: [PATCH 07/20] cosmetic and doc changes --- masque/__init__.py | 6 +++--- setup.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/masque/__init__.py b/masque/__init__.py index 48059b3..7572d32 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -2,9 +2,9 @@ masque 2D CAD library masque is an attempt to make a relatively small library 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), better support for - E-beam doses, and the ability to output to multiple formats. + 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), 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 diff --git a/setup.py b/setup.py index 5dd3085..c9c91ed 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import setup, find_packages -with open('README.md', 'r') as f: +with open('README.md', 'rt') as f: long_description = f.read() with open('masque/VERSION.py', 'rt') as f: @@ -23,7 +23,7 @@ setup(name='masque', ] }, install_requires=[ - 'numpy', + 'numpy', ], extras_require={ 'gdsii': ['python-gdsii'], From 74a0a4f7d296a661c24629c3e6335d1119437214 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sun, 1 Nov 2020 19:46:25 -0800 Subject: [PATCH 08/20] bump version to 2.3 --- masque/VERSION.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/VERSION.py b/masque/VERSION.py index c80f65a..f525959 100644 --- a/masque/VERSION.py +++ b/masque/VERSION.py @@ -1,4 +1,4 @@ """ VERSION defintion. THIS FILE IS MANUALLY PARSED BY setup.py and REQUIRES A SPECIFIC FORMAT """ __version__ = ''' -2.2 +2.3 ''' From 6ead7231a85670d06063fd114b365c91e9604780 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 2 Nov 2020 20:56:56 -0800 Subject: [PATCH 09/20] make `raw_mode`` an arg to `read()` --- masque/file/klamath.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/masque/file/klamath.py b/masque/file/klamath.py index 1c22e73..c4c2a2b 100644 --- a/masque/file/klamath.py +++ b/masque/file/klamath.py @@ -188,6 +188,7 @@ def readfile(filename: Union[str, pathlib.Path], def read(stream: BinaryIO, + raw_mode: 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 @@ -202,12 +203,12 @@ def read(stream: BinaryIO, Args: stream: Stream to read from. + 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 """ - raw_mode = True # Whether to construct shapes in raw mode (less error checking) library_info = _read_header(stream) patterns = [] @@ -253,7 +254,7 @@ def read_elements(stream: BinaryIO, stream: Seekable stream, positioned at a record boundary. Will be read until an ENDSTR record is consumed. name: Name of the resulting Pattern - raw_mode: If True, bypass per-shape consistency checking + raw_mode: If True, bypass per-shape data validation. Default True. Returns: A pattern containing the elements that were read. From 6402c6242e5a9e0db0d5c5e3e4dbcbac0d3afc56 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 2 Nov 2020 20:57:29 -0800 Subject: [PATCH 10/20] add `full_load` option to `load_library` --- masque/file/klamath.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/masque/file/klamath.py b/masque/file/klamath.py index c4c2a2b..e7015be 100644 --- a/masque/file/klamath.py +++ b/masque/file/klamath.py @@ -552,6 +552,8 @@ def disambiguate_pattern_names(patterns: Sequence[Pattern], def load_library(stream: BinaryIO, tag: str, is_secondary: Optional[Callable[[str], bool]] = None, + *, + full_load: bool = False, ) -> Tuple[Library, Dict[str, Any]]: """ Scan a GDSII stream to determine what structures are present, and create @@ -569,6 +571,10 @@ def load_library(stream: BinaryIO, 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. Returns: Library object, allowing for deferred load of structures. @@ -580,10 +586,19 @@ def load_library(stream: BinaryIO, assert(is_secondary is not None) stream.seek(0) + lib = Library() + + if full_load: + # Full load approach (immediately load everything) + patterns, library_info = read(stream) + for name, pattern in patterns.items(): + lib.set_const(name, tag, pattern, secondary=is_secondary(name)) + return lib, library_info + + # Normal approach (scan and defer load) library_info = _read_header(stream) structs = klamath.library.scan_structs(stream) - lib = Library() for name_bytes, pos in structs.items(): name = name_bytes.decode('ASCII') @@ -599,7 +614,9 @@ def load_library(stream: BinaryIO, def load_libraryfile(filename: Union[str, pathlib.Path], tag: str, is_secondary: Optional[Callable[[str], bool]] = None, + *, use_mmap: bool = True, + full_load: bool = False, ) -> Tuple[Library, Dict[str, Any]]: """ Wrapper for `load_library()` that takes a filename or path instead of a stream. @@ -617,6 +634,7 @@ def load_libraryfile(filename: Union[str, pathlib.Path], 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`. Returns: Library object, allowing for deferred load of structures. From 3653a3353430d0853f71434fab19e70db1e66e08 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 9 Nov 2020 21:55:46 -0800 Subject: [PATCH 11/20] add implementation notes --- masque/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/masque/__init__.py b/masque/__init__.py index 7572d32..d1fbd34 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -15,6 +15,17 @@ Note that the methods for these classes try to avoid copying wherever possible, so unless otherwise noted, assume that arguments are stored by-reference. + + + 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, 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 .error import PatternError, PatternLockedError From 4308bdeb5615ad14f5474b7f58bfb1fe62c42b52 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 9 Nov 2020 21:59:28 -0800 Subject: [PATCH 12/20] Add parent class MasqueError and use it for traits and all other exceptions --- masque/error.py | 16 +++++++++------- masque/traits/annotatable.py | 4 ++-- masque/traits/doseable.py | 4 ++-- masque/traits/mirrorable.py | 2 +- masque/traits/positionable.py | 4 ++-- masque/traits/repeatable.py | 4 ++-- masque/traits/rotatable.py | 4 ++-- masque/traits/scalable.py | 6 +++--- 8 files changed, 23 insertions(+), 21 deletions(-) diff --git a/masque/error.py b/masque/error.py index e109c20..690550a 100644 --- a/masque/error.py +++ b/masque/error.py @@ -1,13 +1,15 @@ -class PatternError(Exception): +class MasqueError(Exception): """ - Simple Exception for Pattern objects and their contents + Parent exception for all Masque-related Exceptions """ - def __init__(self, value): - self.value = value + pass - def __str__(self): - return repr(self.value) +class PatternError(MasqueError): + """ + Exception for Pattern objects and their contents + """ + pass class PatternLockedError(PatternError): """ @@ -17,7 +19,7 @@ class PatternLockedError(PatternError): PatternError.__init__(self, 'Tried to modify a locked Pattern, subpattern, or shape') -class LibraryError(Exception): +class LibraryError(MasqueError): """ Exception raised by Library classes """ diff --git a/masque/traits/annotatable.py b/masque/traits/annotatable.py index 9d49018..4c0fdaa 100644 --- a/masque/traits/annotatable.py +++ b/masque/traits/annotatable.py @@ -3,7 +3,7 @@ from typing import TypeVar from abc import ABCMeta, abstractmethod from ..utils import annotations_t -from ..error import PatternError +from ..error import MasqueError T = TypeVar('T', bound='Annotatable') @@ -51,5 +51,5 @@ class AnnotatableImpl(Annotatable, metaclass=ABCMeta): @annotations.setter def annotations(self, annotations: annotations_t): if not isinstance(annotations, dict): - raise PatternError(f'annotations expected dict, got {type(annotations)}') + raise MasqueError(f'annotations expected dict, got {type(annotations)}') self._annotations = annotations diff --git a/masque/traits/doseable.py b/masque/traits/doseable.py index 217872c..8fd770a 100644 --- a/masque/traits/doseable.py +++ b/masque/traits/doseable.py @@ -1,7 +1,7 @@ from typing import TypeVar from abc import ABCMeta, abstractmethod -from ..error import PatternError +from ..error import MasqueError T = TypeVar('T', bound='Doseable') @@ -65,7 +65,7 @@ class DoseableImpl(Doseable, metaclass=ABCMeta): @dose.setter def dose(self, val: float): if not val >= 0: - raise PatternError('Dose must be non-negative') + raise MasqueError('Dose must be non-negative') self._dose = val ''' diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index 1ec54f6..8fb5d4b 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -50,7 +50,7 @@ class Mirrorable(metaclass=ABCMeta): # @mirrored.setter # def mirrored(self, val: Sequence[bool]): # if is_scalar(val): -# raise PatternError('Mirrored must be a 2-element list of booleans') +# raise MasqueError('Mirrored must be a 2-element list of booleans') # self._mirrored = numpy.array(val, dtype=bool, copy=True) # # ''' diff --git a/masque/traits/positionable.py b/masque/traits/positionable.py index 71f90ec..4e59e32 100644 --- a/masque/traits/positionable.py +++ b/masque/traits/positionable.py @@ -4,7 +4,7 @@ from typing import TypeVar from abc import ABCMeta, abstractmethod import numpy # type: ignore -from ..error import PatternError +from ..error import MasqueError from ..utils import vector2 @@ -97,7 +97,7 @@ class PositionableImpl(Positionable, metaclass=ABCMeta): val = numpy.array(val, dtype=float) if val.size != 2: - raise PatternError('Offset must be convertible to size-2 ndarray') + raise MasqueError('Offset must be convertible to size-2 ndarray') self._offset = val.flatten() ''' diff --git a/masque/traits/repeatable.py b/masque/traits/repeatable.py index 4a7f391..9f032b7 100644 --- a/masque/traits/repeatable.py +++ b/masque/traits/repeatable.py @@ -1,7 +1,7 @@ from typing import TypeVar, Optional, TYPE_CHECKING from abc import ABCMeta, abstractmethod -from ..error import PatternError +from ..error import MasqueError if TYPE_CHECKING: @@ -71,7 +71,7 @@ class RepeatableImpl(Repeatable, metaclass=ABCMeta): def repetition(self, repetition: Optional['Repetition']): from ..repetition import Repetition if repetition is not None and not isinstance(repetition, Repetition): - raise PatternError(f'{repetition} is not a valid Repetition object!') + raise MasqueError(f'{repetition} is not a valid Repetition object!') self._repetition = repetition ''' diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index c0641f0..67a9371 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -5,7 +5,7 @@ import numpy # type: ignore from numpy import pi #from .positionable import Positionable -from ..error import PatternError +from ..error import MasqueError from ..utils import is_scalar, rotation_matrix_2d, vector2 T = TypeVar('T', bound='Rotatable') @@ -57,7 +57,7 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): @rotation.setter def rotation(self, val: float): if not is_scalar(val): - raise PatternError('Rotation must be a scalar') + raise MasqueError('Rotation must be a scalar') self._rotation = val % (2 * pi) ''' diff --git a/masque/traits/scalable.py b/masque/traits/scalable.py index b31c2f9..ec87c69 100644 --- a/masque/traits/scalable.py +++ b/masque/traits/scalable.py @@ -1,7 +1,7 @@ from typing import TypeVar from abc import ABCMeta, abstractmethod -from ..error import PatternError +from ..error import MasqueError from ..utils import is_scalar @@ -51,9 +51,9 @@ class ScalableImpl(Scalable, metaclass=ABCMeta): @scale.setter def scale(self, val: float): if not is_scalar(val): - raise PatternError('Scale must be a scalar') + raise MasqueError('Scale must be a scalar') if not val > 0: - raise PatternError('Scale must be positive') + raise MasqueError('Scale must be positive') self._scale = val ''' From 09a5e4a2dbb6df9d6be4f7e92b48f829203d6a40 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 9 Nov 2020 22:04:04 -0800 Subject: [PATCH 13/20] type-related fixes and improvements --- masque/file/dxf.py | 4 ++-- masque/file/oasis.py | 2 +- masque/label.py | 10 +++++----- masque/pattern.py | 15 ++++++++------- masque/subpattern.py | 2 +- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/masque/file/dxf.py b/masque/file/dxf.py index 906fc2f..f99455b 100644 --- a/masque/file/dxf.py +++ b/masque/file/dxf.py @@ -70,7 +70,7 @@ def write(pattern: Pattern, """ #TODO consider supporting DXF arcs? if disambiguate_func is None: - disambiguate_func = disambiguate_pattern_names + disambiguate_func = lambda pats: disambiguate_pattern_names(pats) assert(disambiguate_func is not None) if not modify_originals: @@ -349,7 +349,7 @@ def _mlayer2dxf(layer: layer_t) -> str: raise PatternError(f'Unknown layer type: {layer} ({type(layer)})') -def disambiguate_pattern_names(patterns: Sequence[Pattern], +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 diff --git a/masque/file/oasis.py b/masque/file/oasis.py index dea703d..92abbfd 100644 --- a/masque/file/oasis.py +++ b/masque/file/oasis.py @@ -674,7 +674,7 @@ def annotations_to_properties(annotations: annotations_t) -> List[fatrec.Propert for key, values in annotations.items(): vals = [AString(v) if isinstance(v, str) else v for v in values] - properties.append(fatrec.Property(key, vals, is_standard=False)) + properties.append(fatrec.Property(key, vals, is_standard=False)) # type: ignore return properties diff --git a/masque/label.py b/masque/label.py index b436653..d71d69c 100644 --- a/masque/label.py +++ b/masque/label.py @@ -58,11 +58,11 @@ class Label(PositionableImpl, LayerableImpl, LockableImpl, RepeatableImpl, Annot self.set_locked(locked) def __copy__(self: L) -> L: - return Label(string=self.string, - offset=self.offset.copy(), - layer=self.layer, - repetition=self.repetition, - locked=self.locked) + return type(self)(string=self.string, + offset=self.offset.copy(), + layer=self.layer, + repetition=self.repetition, + locked=self.locked) def __deepcopy__(self: L, memo: Dict = None) -> L: memo = {} if memo is None else memo diff --git a/masque/pattern.py b/masque/pattern.py index 2dcd9c6..7cbd06d 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -2,7 +2,7 @@ Base object representing a lithography mask. """ -from typing import List, Callable, Tuple, Dict, Union, Set, Sequence, Optional, Type, overload +from typing import List, Callable, Tuple, Dict, Union, Set, Sequence, Optional, Type, overload, cast from typing import MutableMapping, Iterable, TypeVar, Any import copy import pickle @@ -18,7 +18,8 @@ from .shapes import Shape, Polygon from .label import Label from .utils import rotation_matrix_2d, vector2, normalize_mirror, AutoSlots, annotations_t from .error import PatternError, PatternLockedError -from .traits import LockableImpl, AnnotatableImpl, Scalable +from .traits import LockableImpl, AnnotatableImpl, Scalable, Mirrorable +from .traits import Rotatable, Positionable visitor_function_t = Callable[['Pattern', Tuple['Pattern'], Dict, numpy.ndarray], 'Pattern'] @@ -27,7 +28,7 @@ visitor_function_t = Callable[['Pattern', Tuple['Pattern'], Dict, numpy.ndarray] P = TypeVar('P', bound='Pattern') -class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): +class Pattern(LockableImpl, AnnotatableImpl, Mirrorable, metaclass=AutoSlots): """ 2D layout consisting of some set of shapes, labels, and references to other Pattern objects (via SubPattern). Shapes are assumed to inherit from masque.shapes.Shape or provide equivalent functions. @@ -710,7 +711,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): self """ for entry in chain(self.shapes, self.subpatterns): - entry.rotate(rotation) + cast(Rotatable, entry).rotate(rotation) return self def mirror_element_centers(self: P, axis: int) -> P: @@ -741,7 +742,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): self """ for entry in chain(self.shapes, self.subpatterns): - entry.mirror(axis) + cast(Mirrorable, entry).mirror(axis) return self def mirror(self: P, axis: int) -> P: @@ -856,7 +857,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): """ self.lock() for ss in chain(self.shapes, self.labels): - ss.lock() + ss.lock() # type: ignore # mypy struggles with multiple inheritance :( for sp in self.subpatterns: sp.deeplock() return self @@ -873,7 +874,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): """ self.unlock() for ss in chain(self.shapes, self.labels): - ss.unlock() + ss.unlock() # type: ignore # mypy struggles with multiple inheritance :( for sp in self.subpatterns: sp.deepunlock() return self diff --git a/masque/subpattern.py b/masque/subpattern.py index 89e143e..1d5fb41 100644 --- a/masque/subpattern.py +++ b/masque/subpattern.py @@ -79,7 +79,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi self.dose = dose self.scale = scale if mirrored is None: - mirrored = [False, False] + mirrored = (False, False) self.mirrored = mirrored self.repetition = repetition self.annotations = annotations if annotations is not None else {} From 8e1c05cf731c3cce8ece11013f2b32f1e1fe1a45 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 9 Nov 2020 22:05:56 -0800 Subject: [PATCH 14/20] add mirror2d() to Rotatable --- masque/shapes/text.py | 2 +- masque/subpattern.py | 2 +- masque/traits/mirrorable.py | 18 +++++++++++++++++- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/masque/shapes/text.py b/masque/shapes/text.py index 07cc1a7..d6cb3ac 100644 --- a/masque/shapes/text.py +++ b/masque/shapes/text.py @@ -121,7 +121,7 @@ class Text(RotatableImpl, Shape, metaclass=AutoSlots): # Move these polygons to the right of the previous letter for xys in raw_polys: poly = Polygon(xys, dose=self.dose, layer=self.layer) - [poly.mirror(ax) for ax, do in enumerate(self.mirrored) if do] + poly.mirror2d(self.mirrored) poly.scale_by(self.height) poly.offset = self.offset + [total_advance, 0] poly.rotate_around(self.offset, self.rotation) diff --git a/masque/subpattern.py b/masque/subpattern.py index 1d5fb41..3913b33 100644 --- a/masque/subpattern.py +++ b/masque/subpattern.py @@ -138,7 +138,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi assert(self.pattern is not None) pattern = self.pattern.deepcopy().deepunlock() pattern.scale_by(self.scale) - [pattern.mirror(ax) for ax, do in enumerate(self.mirrored) if do] + pattern.mirror2d(self.mirrored) pattern.rotate_around((0.0, 0.0), self.rotation) pattern.translate_elements(self.offset) pattern.scale_element_doses(self.dose) diff --git a/masque/traits/mirrorable.py b/masque/traits/mirrorable.py index 8fb5d4b..6990e4c 100644 --- a/masque/traits/mirrorable.py +++ b/masque/traits/mirrorable.py @@ -1,4 +1,4 @@ -from typing import TypeVar +from typing import TypeVar, Tuple from abc import ABCMeta, abstractmethod @@ -28,6 +28,22 @@ class Mirrorable(metaclass=ABCMeta): """ pass + def mirror2d(self: T, axes: Tuple[bool, bool]) -> T: + """ + Optionally mirror the entity across both axes + + Args: + axes: (mirror_across_x, mirror_across_y) + + Returns: + self + """ + if axes[0]: + self.mirror(0) + if axes[1]: + self.mirror(1) + return self + #class MirrorableImpl(Mirrorable, metaclass=ABCMeta): # """ From 6c4fe0e9e43c709fdc805e5324dff21161cddb57 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 9 Nov 2020 22:06:19 -0800 Subject: [PATCH 15/20] Add octagon constructor --- masque/shapes/polygon.py | 58 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/masque/shapes/polygon.py b/masque/shapes/polygon.py index 59ab82d..7c48be3 100644 --- a/masque/shapes/polygon.py +++ b/masque/shapes/polygon.py @@ -269,6 +269,64 @@ class Polygon(Shape, metaclass=AutoSlots): layer=layer, dose=dose) return poly + @staticmethod + def octagon(*, + side_length: Optional[float] = None, + inner_radius: Optional[float] = None, + regular: bool = True, + center: vector2 = (0.0, 0.0), + rotation: float = 0.0, + layer: layer_t = 0, + dose: float = 1.0, + ) -> 'Polygon': + """ + Draw an octagon given one of (side length, inradius, circumradius). + + Args: + side_length: Length of one side. For an irregular octagon, this + specifies the length of the long sides. + inner_radius: Half of distance between opposite sides. For an irregular + octagon, this specifies the spacing between the long sides. + regular: If `True`, all sides have the same length. If `False`, + a "clipped square" with vertices (+-1, +-2) and (+-2, +-1) + is generated, avoiding irrational coordinate locations and + guaranteeing 45 degree edges. + center: Offset, default `(0, 0)` + rotation: Rotation counterclockwise, in radians. + `0` results in four axis-aligned sides (the long sides of the + irregular octagon). + layer: Layer, default `0` + dose: Dose, default `1.0` + + Returns: + A Polygon object containing the requested octagon + """ + if regular: + s = 1 + numpy.sqrt(2) + else: + s = 2 + + norm_oct = numpy.array([ + [-1, -s], + [-s, -1], + [-s, 1], + [-1, s], + [ 1, s], + [ s, 1], + [ s, -1], + [ 1, -s]], dtype=float) + + if side_length is None: + if inner_radius is None: + raise PatternError('One of `side_length` or `inner_radius` must be specified.') + side_length = 2 * inner_radius / s + + vertices = 0.5 * side_length * norm_oct + poly = Polygon(vertices, offset=center, layer=layer, dose=dose) + poly.rotate(rotation) + return poly + + def to_polygons(self, poly_num_points: int = None, # unused poly_max_arclen: float = None, # unused From ad51801c5d0b65d7fd10efc63d35f08602feec99 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 9 Nov 2020 22:06:44 -0800 Subject: [PATCH 16/20] minor fixes to rotate() --- masque/traits/rotatable.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/masque/traits/rotatable.py b/masque/traits/rotatable.py index 67a9371..1e83697 100644 --- a/masque/traits/rotatable.py +++ b/masque/traits/rotatable.py @@ -24,12 +24,12 @@ class Rotatable(metaclass=ABCMeta): ---- Abstract methods ''' @abstractmethod - def rotate(self: T, theta: float) -> T: + def rotate(self: T, val: float) -> T: """ Rotate the shape around its origin (0, 0), ignoring its offset. Args: - theta: Angle to rotate by (counterclockwise, radians) + val: Angle to rotate by (counterclockwise, radians) Returns: self @@ -56,7 +56,7 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): @rotation.setter def rotation(self, val: float): - if not is_scalar(val): + if not numpy.size(val) == 1: raise MasqueError('Rotation must be a scalar') self._rotation = val % (2 * pi) From 177f9952a5e876f9aefcf69af06457f89ef293fc Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Mon, 9 Nov 2020 22:09:47 -0800 Subject: [PATCH 17/20] Add builder submodule, Device and Port definitions, and DeviceLibrary --- masque/__init__.py | 2 +- masque/builder/__init__.py | 2 + masque/builder/devices.py | 724 +++++++++++++++++++++++++++++++ masque/builder/utils.py | 189 ++++++++ masque/error.py | 12 + masque/library/__init__.py | 1 + masque/library/device_library.py | 105 +++++ 7 files changed, 1034 insertions(+), 1 deletion(-) create mode 100644 masque/builder/__init__.py create mode 100644 masque/builder/devices.py create mode 100644 masque/builder/utils.py create mode 100644 masque/library/device_library.py diff --git a/masque/__init__.py b/masque/__init__.py index d1fbd34..611d4d4 100644 --- a/masque/__init__.py +++ b/masque/__init__.py @@ -34,7 +34,7 @@ from .label import Label from .subpattern import SubPattern from .pattern import Pattern from .utils import layer_t, annotations_t -from .library import Library +from .library import Library, DeviceLibrary __author__ = 'Jan Petykiewicz' diff --git a/masque/builder/__init__.py b/masque/builder/__init__.py new file mode 100644 index 0000000..50ddcee --- /dev/null +++ b/masque/builder/__init__.py @@ -0,0 +1,2 @@ +from .devices import Port, Device +from .utils import ell diff --git a/masque/builder/devices.py b/masque/builder/devices.py new file mode 100644 index 0000000..1eca581 --- /dev/null +++ b/masque/builder/devices.py @@ -0,0 +1,724 @@ +from typing import Dict, Iterable, List, Tuple, Union, TypeVar, Any, Iterator, Optional, Sequence +import copy +import warnings +import logging +from collections import Counter + +import numpy # type: ignore +from numpy import pi + +from ..pattern import Pattern +from ..subpattern import SubPattern +from ..traits import PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable +from ..utils import AutoSlots, rotation_matrix_2d, vector2 +from ..error import DeviceError + + +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 integer, default of `0`. + """ + __slots__ = ('ptype', '_rotation') + + _rotation: Optional[float] + """ radians counterclockwise from +x, pointing into device body. + Can be `None` to signify undirected port """ + + ptype: int + """ Port types must match to be plugged together if both are non-zero """ + + def __init__(self, + offset: numpy.ndarray, + rotation: Optional[float], + ptype: int = 0, + ) -> 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): + 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: int) -> 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 += 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', '_dead') + + pattern: Pattern + """ Layout of this device """ + + ports: Dict[str, Port] + """ Uniquely-named ports which can be used to snap to other Device instances""" + + _dead: bool + """ If True, plug()/place() are skipped (for debugging)""" + + def __init__(self, + pattern: Optional[Pattern] = None, + ports: Optional[Dict[str, Port]] = 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 `ptype=0`, 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, ptype=0), + 'B': Port([0, 0], rotation=pi, ptype=0), + } + else: + self.ports = copy.deepcopy(ports) + + self._dead = False + + def __getitem__(self, key: Union[str, Iterable[str]]) -> numpy.ndarray: + """ + 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) + 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}) + 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: vector2 = (0, 0), + rotation: float = 0, + pivot: vector2 = (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[numpy.ndarray, float, numpy.ndarray]: + """ + 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 = numpy.array([p.ptype for p in s_ports.values()], dtype=int) + o_types = numpy.array([p.ptype for p in o_ports.values()], dtype=int) + + 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 += pi + if mirrored[1]: + o_offsets[:, 0] *= -1 + o_rotations += pi + + type_conflicts = (s_types != o_types) & (s_types != 0) & (o_types != 0) + 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]:g}:{o_types[nn]:g} | {v}\n' + 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: vector2) -> 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: vector2, 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 __repr__(self) -> str: + s = f' numpy.ndarray: + offsets -= pivot + offsets[:] = (rotation_matrix_2d(angle) @ offsets.T).T + offsets += pivot + return offsets diff --git a/masque/builder/utils.py b/masque/builder/utils.py new file mode 100644 index 0000000..d4a9dbe --- /dev/null +++ b/masque/builder/utils.py @@ -0,0 +1,189 @@ +from typing import Dict, Tuple, List, Optional, Union, Any, cast, Sequence +from pprint import pformat + +import numpy # type: ignore +from numpy import pi + +from .devices import Port +from ..utils import rotation_matrix_2d, vector2 +from ..error import BuildError + + +def ell(ports: Dict[str, Port], + ccw: Optional[bool], + bound_type: str, + bound: Union[float, vector2], + *, + spacing: Optional[Union[float, numpy.ndarray]] = 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: + + =A>---------------------------V turn direction: `ccw=False` + =B>-------------V | + =C>-----------------------V | | + =D=>----------------V | | | + + + x---x---x---x `spacing` (can be scalar or array) + + <--------------> `bound_type='min_extension'` + <------> `'min_past_furthest'` + <--------------------------------> `'max_extension'` + x `'min_position'` + x `'max_position'` + + Args: + ports: `name: port` mapping. All ports should have the same rotation (or `None`). If + no port has a rotation specified, `set_rotation` must be provided. + ccw: Turn direction. `True` means counterclockwise, `False` means clockwise, + and `None` means no bend. If `None`, spacing must remain `None` or `0` (default), + Otherwise, spacing must be set to a non-`None` value. + bound_method: Method used for determining the travel distance; see diagram above. + Valid values are: + - 'min_extension' or 'emin': + The total extension value for the furthest-out port (B in the diagram). + - 'min_past_furthest': + 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' or 'pmin': + The coordinate of the innermost bend (D's bend). + - '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', + 'max_extension', 'min_past_furthest'), it sets independent limits along + the x- and y- axes. If specifying a position, it is projected onto + the extension direction. + + bound_value: Value associated with `bound_type`, see above. + spacing: Distance between adjacent channels. Can be scalar, resulting in evenly + spaced channels, or a vector with length one less than `ports`, allowing + non-uniform spacing. + The ordering of the vector corresponds to the output order (DCBA in the + diagram above), *not* the order of `ports`. + set_rotation: If all ports have no specified rotation, this value is used + to set the extension direction. Otherwise it must remain `None`. + + Returns: + Dict of {port_name: distance_to_bend} + + Raises: + `BuildError` on bad inputs + `BuildError` if the requested bound is impossible + """ + if not ports: + raise BuildError('Empty port list passed to `ell()`') + + if ccw is None: + if spacing is not None and not numpy.isclose(spacing, 0): + raise BuildError('Spacing must be 0 or None when ccw=None') + spacing = 0 + elif spacing is None: + raise BuildError('Must provide spacing if a bend direction is specified') + + has_rotation = numpy.array([p.rotation is not None for p in ports.values()], dtype=bool) + if has_rotation.any(): + if set_rotation is not None: + raise BuildError('set_rotation must be None when ports have rotations!') + + rotations = numpy.array([p.rotation if p.rotation is not None else 0 + for p in ports.values()]) + rotations[~has_rotation] = rotations[has_rotation][0] + + if not numpy.allclose(rotations[0], rotations): + raise BuildError('Asked to find aggregation for ports that face in different directions:\n' + + pformat({k: numpy.rad2deg(p.rotation) for k, p in ports.items()})) + else: + if set_rotation is not None: + raise BuildError('set_rotation must be specified if no ports have rotations!') + rotations = numpy.full_like(has_rotation, set_rotation, dtype=float) + + direction = rotations[0] + pi # direction we want to travel in (+pi relative to port) + rot_matrix = rotation_matrix_2d(-direction) + + # Rotate so are traveling in +x + orig_offsets = numpy.array([p.offset for p in ports.values()]) + rot_offsets = (rot_matrix @ orig_offsets.T).T + + y_order = ((-1 if ccw else 1) * rot_offsets[:, 1]).argsort() + y_ind = numpy.empty_like(y_order, dtype=int) + y_ind[y_order] = numpy.arange(y_ind.shape[0]) + + if spacing is None: + ch_offsets = numpy.zeros_like(y_order) + else: + steps = numpy.zeros_like(y_order) + steps[1:] = spacing + ch_offsets = numpy.cumsum(steps)[y_ind] + + x_start = rot_offsets[:, 0] + + # A---------| `d_to_align[0]` + # B `d_to_align[1]` + # C-------------| `d_to_align[2]` + # D-----------| `d_to_align[3]` + # + d_to_align = x_start.max() - x_start # distance to travel to align all + if bound_type == 'min_past_furthest': + # A------------------V `d_to_exit[0]` + # B-----V `d_to_exit[1]` + # C----------------V `d_to_exit[2]` + # D-----------V `d_to_exit[3]` + offsets = d_to_align + ch_offsets + else: + # A---------V `travel[0]` <-- Outermost port aligned to furthest-x port + # V--B `travel[1]` <-- Remaining ports follow spacing + # C-------V `travel[2]` + # D--V `travel[3]` + # + # A------------V `offsets[0]` + # B `offsets[1]` <-- Travels adjusted to be non-negative + # C----------V `offsets[2]` + # D-----V `offsets[3]` + travel = d_to_align - (ch_offsets.max() - ch_offsets) + offsets = travel - travel.min().clip(max=0) + + if bound_type in ('emin', 'min_extension', + 'emax', 'max_extension', + 'min_past_furthest',): + if numpy.size(bound) == 2: + bound = cast(Sequence[float], bound) + rot_bound = (rot_matrix @ ((bound[0], 0), + (0, bound[1])))[0, :] + else: + bound = cast(float, bound) + rot_bound = numpy.array(bound) + + if rot_bound < 0: + raise BuildError(f'Got negative bound for extension: {rot_bound}') + + if bound_type in ('emin', 'min_extension', 'min_past_furthest'): + offsets += rot_bound.max() + elif bound_type in('emax', 'max_extension'): + offsets += rot_bound.min() - offsets.max() + else: + if numpy.size(bound) == 2: + bound = cast(Sequence[float], bound) + rot_bound = (rot_matrix @ bound)[0] + else: + 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'): + extension = rot_bound - min_possible.max() + elif bound_type in ('pmin', 'min_position'): + extension = rot_bound - min_possible.min() + + offsets += extension + if extension < 0: + 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)) + return result diff --git a/masque/error.py b/masque/error.py index 690550a..84607b7 100644 --- a/masque/error.py +++ b/masque/error.py @@ -26,3 +26,15 @@ class LibraryError(MasqueError): pass +class DeviceError(MasqueError): + """ + Exception raised by Device and Port objects + """ + pass + + +class BuildError(MasqueError): + """ + Exception raised by builder-related functions + """ + pass diff --git a/masque/library/__init__.py b/masque/library/__init__.py index a72f3b9..0d40b3b 100644 --- a/masque/library/__init__.py +++ b/masque/library/__init__.py @@ -1 +1,2 @@ from .library import Library, PatternGenerator +from .device_library import DeviceLibrary diff --git a/masque/library/device_library.py b/masque/library/device_library.py new file mode 100644 index 0000000..cf06e72 --- /dev/null +++ b/masque/library/device_library.py @@ -0,0 +1,105 @@ +""" +DeviceLibrary class for managing unique name->device mappings and + deferred loading or creation. +""" +from typing import Dict, Callable, TypeVar, TYPE_CHECKING +from typing import Any, Tuple, Union, Iterator +import logging +from pprint import pformat + +from ..error import LibraryError + +if TYPE_CHECKING: + from ..builder import Device + + +logger = logging.getLogger(__name__) + + +L = TypeVar('L', bound='DeviceLibrary') + + +class DeviceLibrary: + """ + This class is usually used to create a device library by mapping names to + functions which generate or load the relevant `Device` object as-needed. + + The cache can be disabled by setting the `enable_cache` attribute to `False`. + """ + generators: Dict[str, Callable[[], 'Device']] + cache: Dict[Union[str, Tuple[str, str]], 'Device'] + enable_cache: bool = True + + def __init__(self) -> None: + self.generators = {} + self.cache = {} + + def __setitem__(self, key: str, value: Callable[[], 'Device']) -> None: + self.generators[key] = value + if key in self.cache: + del self.cache[key] + + def __delitem__(self, key: str) -> None: + del self.generators[key] + if key in self.cache: + del self.cache[key] + + def __getitem__(self, key: str) -> 'Device': + if self.enable_cache and key in self.cache: + logger.debug(f'found {key} in cache') + return self.cache[key] + + logger.debug(f'loading {key}') + dev = self.generators[key]() + self.cache[key] = dev + return dev + + def __iter__(self) -> Iterator[str]: + return iter(self.keys()) + + def __contains__(self, key: str) -> bool: + return key in self.generators + + def keys(self) -> Iterator[str]: + return iter(self.generators.keys()) + + def values(self) -> Iterator['Device']: + return iter(self[key] for key in self.keys()) + + def items(self) -> Iterator[Tuple[str, 'Device']]: + return iter((key, self[key]) for key in self.keys()) + + def __repr__(self) -> str: + return '' + + def set_const(self, key: str, const: 'Device') -> None: + """ + Convenience function to avoid having to manually wrap + constant values into callables. + + Args: + key: Lookup key, usually the device name + const: Device object to return + """ + self.generators[key] = lambda: const + + def add(self: L, other: L) -> L: + """ + Add keys from another library into this one. + + There must be no conflicting keys. + + Args: + other: The library to insert keys from + + Returns: + self + """ + conflicts = [key for key in other.generators + if key in self.generators] + if conflicts: + raise LibraryError('Duplicate keys encountered in library merge: ' + pformat(conflicts)) + + self.generators.update(other.generators) + self.cache.update(other.cache) + return self From b7383a30ca33f0d6d352be4d95d12ff84f7827d2 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 5 Dec 2020 14:49:57 -0800 Subject: [PATCH 18/20] fix mirroring across y for arcs and ellipses --- masque/shapes/arc.py | 1 + masque/shapes/ellipse.py | 1 + 2 files changed, 2 insertions(+) diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index 0416d01..b12113c 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -326,6 +326,7 @@ class Arc(Shape, metaclass=AutoSlots): def mirror(self, axis: int) -> 'Arc': self.offset[axis - 1] *= -1 self.rotation *= -1 + self.rotation += axis * pi self.angles *= -1 return self diff --git a/masque/shapes/ellipse.py b/masque/shapes/ellipse.py index 140f590..f9aefbf 100644 --- a/masque/shapes/ellipse.py +++ b/masque/shapes/ellipse.py @@ -183,6 +183,7 @@ class Ellipse(Shape, metaclass=AutoSlots): def mirror(self, axis: int) -> 'Ellipse': self.offset[axis - 1] *= -1 self.rotation *= -1 + self.rotation += axis * pi return self def scale_by(self, c: float) -> 'Ellipse': From 479e0c3c2e0f73a3228a7eec56a5b3ee3a0567a7 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 5 Dec 2020 14:50:16 -0800 Subject: [PATCH 19/20] fix mirroring for rotated ports/devices --- masque/builder/devices.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/masque/builder/devices.py b/masque/builder/devices.py index 1eca581..519462f 100644 --- a/masque/builder/devices.py +++ b/masque/builder/devices.py @@ -79,7 +79,8 @@ class Port(PositionableImpl, Rotatable, PivotableImpl, Copyable, Mirrorable, met def mirror(self: P, axis: int) -> P: self.offset[1 - axis] *= -1 if self.rotation is not None: - self.rotation += pi + self.rotation *= -1 + self.rotation += axis * pi return self def rotate(self: P, rotation: float) -> P: @@ -606,9 +607,10 @@ class Device(Copyable, Mirrorable): if mirrored[0]: o_offsets[:, 1] *= -1 - o_rotations += pi + o_rotations *= -1 if mirrored[1]: o_offsets[:, 0] *= -1 + o_rotations *= -1 o_rotations += pi type_conflicts = (s_types != o_types) & (s_types != 0) & (o_types != 0) From 0fce7fd099c20921a049d5293baccf5f486ab598 Mon Sep 17 00:00:00 2001 From: Jan Petykiewicz Date: Sat, 5 Dec 2020 14:52:55 -0800 Subject: [PATCH 20/20] bump version to v2.4 --- masque/VERSION.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/masque/VERSION.py b/masque/VERSION.py index f525959..12d4cbb 100644 --- a/masque/VERSION.py +++ b/masque/VERSION.py @@ -1,4 +1,4 @@ """ VERSION defintion. THIS FILE IS MANUALLY PARSED BY setup.py and REQUIRES A SPECIFIC FORMAT """ __version__ = ''' -2.3 +2.4 '''