diff --git a/examples/pcgen.py b/examples/pcgen.py new file mode 100644 index 0000000..3c25ed6 --- /dev/null +++ b/examples/pcgen.py @@ -0,0 +1,298 @@ +""" +Routines for creating normalized 2D lattices and common photonic crystal + cavity designs. +""" + +from typing import Sequence, Tuple + +import numpy # type: ignore + + +def triangular_lattice(dims: Tuple[int, int], + asymmetric: bool = False, + origin: str = 'center', + ) -> numpy.ndarray: + """ + Return an ndarray of `[[x0, y0], [x1, y1], ...]` denoting lattice sites for + a triangular lattice in 2D. + + Args: + dims: Number of lattice sites in the [x, y] directions. + asymmetric: If true, each row will contain the same number of + x-coord lattice sites. If false, every other row will be + one site shorter (to make the structure symmetric). + origin: If 'corner', the least-(x,y) lattice site is placed at (0, 0) + If 'center', the center of the lattice (not necessarily a + lattice site) is placed at (0, 0). + + 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 % 2 == 1] += 0.5 + sy *= numpy.sqrt(3) / 2 + + if not asymmetric: + which = sx != sx.max() + sx = sx[which] + sy = sy[which] + + xy = numpy.column_stack((sx.flat, sy.flat)) + + if origin == 'center': + xy -= (xy.max(axis=0) - xy.min(axis=0)) / 2 + elif origin == 'corner': + pass + else: + raise Exception(f'Invalid value for `origin`: {origin}') + + return xy[xy[:, 0].argsort(), :] + + +def square_lattice(dims: Tuple[int, int]) -> numpy.ndarray: + """ + Return an ndarray of `[[x0, y0], [x1, y1], ...]` denoting lattice sites for + a square lattice in 2D. The lattice will be centered around (0, 0). + + Args: + dims: Number of lattice sites in the [x, y] directions. + + Returns: + `[[x0, y0], [x1, 1], ...]` denoting lattice sites. + """ + xs, ys = numpy.meshgrid(range(dims[0]), range(dims[1]), 'xy') + xs -= dims[0]/2 + ys -= dims[1]/2 + xy = numpy.vstack((xs.flatten(), ys.flatten())).T + return xy[xy[:, 0].argsort(), ] + + +# ### Photonic crystal functions ### + + +def nanobeam_holes(a_defect: float, + num_defect_holes: int, + num_mirror_holes: int + ) -> numpy.ndarray: + """ + Returns a list of `[[x0, r0], [x1, r1], ...]` of nanobeam hole positions and radii. + Creates a region in which the lattice constant and radius are progressively + (linearly) altered over num_defect_holes holes until they reach the value + specified by a_defect, then symmetrically returned to a lattice constant and + radius of 1, which is repeated num_mirror_holes times on each side. + + Args: + a_defect: Minimum lattice constant for the defect, as a fraction of the + mirror lattice constant (ie., for no defect, a_defect = 1). + num_defect_holes: How many holes form the defect (per-side) + num_mirror_holes: How many holes form the mirror (per-side) + + Returns: + Ndarray `[[x0, r0], [x1, r1], ...]` of nanobeam hole positions and radii. + """ + a_values = numpy.linspace(a_defect, 1, num_defect_holes, endpoint=False) + xs = a_values.cumsum() - (a_values[0] / 2) # Later mirroring makes center distance 2x as long + mirror_xs = numpy.arange(1, num_mirror_holes + 1, dtype=float) + xs[-1] + mirror_rs = numpy.ones_like(mirror_xs) + return numpy.vstack((numpy.hstack((-mirror_xs[::-1], -xs[::-1], xs, mirror_xs)), + numpy.hstack((mirror_rs[::-1], a_values[::-1], a_values, mirror_rs)))).T + + +def waveguide(length: int, num_mirror: int) -> numpy.ndarray: + """ + Line defect waveguide in a triangular lattice. + + Args: + length: waveguide length (number of holes in x direction) + num_mirror: Mirror length (number of holes per side; total size is + `2 * n + 1` holes. + + Returns: + `[[x0, y0], [x1, y1], ...]` for all the holes + """ + p = triangular_lattice([length, 2 * num_mirror + 1]) + p_wg = p[p[:, 1] != 0, :] + return p_wg + + +def wgbend(num_mirror: int) -> numpy.ndarray: + """ + Line defect waveguide bend in a triangular lattice. + + Args: + num_mirror: Mirror length (number of holes per side; total size is + approximately `2 * n + 1` + + Returns: + `[[x0, y0], [x1, y1], ...]` for all the holes + """ + p = triangular_lattice([2 * num_mirror, 2 * num_mirror + 1]) + left_horiz = (p[:, 1] == 0) & (p[:, 0] <= 0) + p = p[~left_horiz, :] + + right_diag = numpy.isclose(p[:, 1], p[:, 0] * numpy.sqrt(3)) & (p[:, 0] >= 0) + p = p[~right_diag, :] + return p + + +def y_splitter(num_mirror: int) -> numpy.ndarray: + """ + Line defect waveguide y-splitter in a triangular lattice. + + Args: + num_mirror: Mirror length (number of holes per side; total size is + approximately `2 * n + 1` holes. + + Returns: + `[[x0, y0], [x1, y1], ...]` for all the holes + """ + p = triangular_lattice([2 * num_mirror, 2 * num_mirror + 1]) + left_horiz = (p[:, 1] == 0) & (p[:, 0] <= 0) + p = p[~left_horiz, :] + + right_diag_up = numpy.isclose(p[:, 1], p[:, 0] * numpy.sqrt(3)) & (p[:, 0] >= 0) + p = p[~right_diag_up, :] + + right_diag_dn = numpy.isclose(p[:, 1], -p[:, 0] * numpy.sqrt(3)) & (p[:, 0] >= 0) + p = p[~right_diag_dn, :] + return p + + +def ln_defect(mirror_dims: Tuple[int, int], + defect_length: int, + ) -> numpy.ndarray: + """ + N-hole defect in a triangular lattice. + + Args: + mirror_dims: [x, y] mirror lengths (number of holes). Total number of holes + is 2 * n + 1 in each direction. + defect_length: Length of defect. Should be an odd number. + + Returns: + `[[x0, y0], [x1, y1], ...]` for all the holes + """ + if defect_length % 2 != 1: + raise Exception('defect_length must be odd!') + p = triangular_lattice([2 * d + 1 for d in mirror_dims]) + half_length = numpy.floor(defect_length / 2) + hole_nums = numpy.arange(-half_length, half_length + 1) + holes_to_keep = numpy.in1d(p[:, 0], hole_nums, invert=True) + return p[numpy.logical_or(holes_to_keep, p[:, 1] != 0), ] + + +def ln_shift_defect(mirror_dims: Tuple[int, int], + defect_length: int, + shifts_a: Sequence[float] = (0.15, 0, 0.075), + shifts_r: Sequence[float] = (1, 1, 1) + ) -> numpy.ndarray: + """ + N-hole defect with shifted holes (intended to give the mode a gaussian profile + in real- and k-space so as to improve both Q and confinement). Holes along the + defect line are shifted and altered according to the shifts_* parameters. + + Args: + mirror_dims: [x, y] mirror lengths (number of holes). Total number of holes + is `2 * n + 1` in each direction. + defect_length: Length of defect. Should be an odd number. + shifts_a: Percentage of a to shift (1st, 2nd, 3rd,...) holes along the defect line + shifts_r: Factor to multiply the radius by. Should match length of shifts_a + + Returns: + `[[x0, y0, r0], [x1, y1, r1], ...]` for all the holes + """ + if not hasattr(shifts_a, "__len__") and shifts_a is not None: + shifts_a = [shifts_a] + if not hasattr(shifts_r, "__len__") and shifts_r is not None: + shifts_r = [shifts_r] + + xy = ln_defect(mirror_dims, defect_length) + + # Add column for radius + xyr = numpy.hstack((xy, numpy.ones((xy.shape[0], 1)))) + + # Shift holes + # Expand shifts as necessary + n_shifted = max(len(shifts_a), len(shifts_r)) + + tmp_a = numpy.array(shifts_a) + shifts_a = numpy.ones((n_shifted, )) + shifts_a[:len(tmp_a)] = tmp_a + + tmp_r = numpy.array(shifts_r) + shifts_r = numpy.ones((n_shifted, )) + shifts_r[:len(tmp_r)] = tmp_r + + x_removed = numpy.floor(defect_length / 2) + + for ind in range(n_shifted): + for sign in (-1, 1): + x_val = sign * (x_removed + ind + 1) + which = numpy.logical_and(xyr[:, 0] == x_val, xyr[:, 1] == 0) + xyr[which, ] = (x_val + numpy.sign(x_val) * shifts_a[ind], 0, shifts_r[ind]) + + return xyr + + +def r6_defect(mirror_dims: Tuple[int, int]) -> numpy.ndarray: + """ + R6 defect in a triangular lattice. + + Args: + mirror_dims: [x, y] mirror lengths (number of holes). Total number of holes + is 2 * n + 1 in each direction. + + Returns: + `[[x0, y0], [x1, y1], ...]` specifying hole centers. + """ + xy = triangular_lattice([2 * d + 1 for d in mirror_dims]) + + rem_holes_plus = numpy.array([[1, 0], + [0.5, +numpy.sqrt(3)/2], + [0.5, -numpy.sqrt(3)/2]]) + rem_holes = numpy.vstack((rem_holes_plus, -rem_holes_plus)) + + for rem_xy in rem_holes: + xy = xy[(xy != rem_xy).any(axis=1), ] + + return xy + + +def l3_shift_perturbed_defect( + mirror_dims: Tuple[int, int], + perturbed_radius: float = 1.1, + shifts_a: Sequence[float] = (), + shifts_r: Sequence[float] = () + ) -> numpy.ndarray: + """ + 3-hole defect with perturbed hole sizes intended to form an upwards-directed + beam. Can also include shifted holes along the defect line, intended + to give the mode a more gaussian profile to improve Q. + + Args: + mirror_dims: [x, y] mirror lengths (number of holes). Total number of holes + is 2 * n + 1 in each direction. + perturbed_radius: Amount to perturb the radius of the holes used for beam-forming + shifts_a: Percentage of a to shift (1st, 2nd, 3rd,...) holes along the defect line + shifts_r: Factor to multiply the radius by. Should match length of shifts_a + + Returns: + `[[x0, y0, r0], [x1, y1, r1], ...]` for all the holes + """ + xyr = ln_shift_defect(mirror_dims, 3, shifts_a, shifts_r) + + abs_x, abs_y = (numpy.fabs(xyr[:, i]) for i in (0, 1)) + + # Sorted unique xs and ys + # Ignore row y=0 because it might have shifted holes + xs = numpy.unique(abs_x[abs_x != 0]) + ys = numpy.unique(abs_y) + + # which holes should be perturbed? (xs[[3, 7]], ys[1]) and (xs[[2, 6]], ys[2]) + perturbed_holes = ((xs[a], ys[b]) for a, b in ((3, 1), (7, 1), (2, 2), (6, 2))) + for row in xyr: + if numpy.fabs(row) in perturbed_holes: + row[2] = perturbed_radius + return xyr diff --git a/examples/phc.py b/examples/phc.py new file mode 100644 index 0000000..f214a5a --- /dev/null +++ b/examples/phc.py @@ -0,0 +1,178 @@ +from typing import Tuple, Sequence + +import numpy # type: ignore +from numpy import pi + +from masque import layer_t, Pattern, SubPattern, Label +from masque.shapes import Polygon, Circle +from masque.builder import Device, Port +from masque.library import Library, DeviceLibrary +from masque.file.klamath import writefile + +import pcgen + + +HOLE_SCALE: float = 1000 +''' Radius for the 'hole' cell. Should be significantly bigger than + 1 (minimum database unit) in order to have enough precision to + reasonably represent a polygonized circle (for GDS) +''' + +def hole(layer: layer_t, + radius: float = HOLE_SCALE * 0.35, + ) -> Pattern: + """ + Generate a pattern containing a single circular hole. + + Args: + layer: Layer to draw the circle on. + radius: Circle radius. + + Returns: + Pattern, named `'hole'` + """ + pat = Pattern('hole', shapes=[ + Circle(radius=radius, offset=(0, 0), layer=layer, dose=1.0) + ]) + return pat + + +def perturbed_l3(lattice_constant: float, + 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), + perturbed_radius: float = 1.1, + trench_width: float = 1200, + ) -> Device: + """ + Generate a `Device` representing a perturbed L3 cavity. + + Args: + lattice_constant: Distance between nearest neighbor holes + 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 + the defect (same row). Default `(0.15, 0, 0.075)` for first, + second, third holes. + shifts_r: passed to `pcgen.l3_shift`; specifies radius for perturbing + holes adjacent to the defect (same row). Default 1.0 for all holes. + Provided sequence should have same length as `shifts_a`. + xy_size: `(x, y)` number of mirror periods in each direction; total size is + `2 * n + 1` holes in each direction. Default (10, 10). + perturbed_radius: radius of holes perturbed to form an upwards-driected beam + (multiplicative factor). Default 1.1. + trench width: Width of the undercut trenches. Default 1200. + + Returns: + `Device` object representing the L3 design. + """ + xyr = pcgen.l3_shift_perturbed_defect(mirror_dims=xy_size, + perturbed_radius=perturbed_radius, + shifts_a=shifts_a, + shifts_r=shifts_r) + + pat = Pattern(f'L3p-a{lattice_constant:g}rp{perturbed_radius:g}') + pat.subpatterns += [SubPattern(hole, offset=(lattice_constant * x, + lattice_constant * y), scale=r * lattice_constant / HOLE_SCALE) + for x, y, r in xyr] + + min_xy, max_xy = pat.get_bounds() + trench_dx = max_xy[0] - min_xy[0] + + 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 = { + 'input': Port((-lattice_constant * xy_size[0], 0), rotation=0, ptype=1), + 'output': Port((lattice_constant * xy_size[0], 0), rotation=pi, ptype=1), + } + + return Device(pat, ports) + + +def waveguide(lattice_constant: float, + hole: Pattern, + length: int, + mirror_periods: int, + ) -> Device: + xy = pcgen.waveguide(length=length + 2, num_mirror=mirror_periods) + + pat = Pattern(f'_wg-a{lattice_constant:g}l{length}') + pat.subpatterns += [SubPattern(hole, offset=(lattice_constant * x, + lattice_constant * y), scale=lattice_constant / HOLE_SCALE) + for x, y in xy] + + ports = { + 'left': Port((-lattice_constant * length / 2, 0), rotation=0, ptype=1), + 'right': Port((lattice_constant * length / 2, 0), rotation=pi, ptype=1), + } + return Device(pat, ports) + + +def bend(lattice_constant: float, + hole: Pattern, + mirror_periods: int, + ) -> Device: + xy = pcgen.wgbend(num_mirror=mirror_periods) + + pat_half = Pattern(f'_wgbend_half-a{lattice_constant:g}l{mirror_periods}') + pat_half.subpatterns += [SubPattern(hole, offset=(lattice_constant * x, + lattice_constant * y), scale=lattice_constant / HOLE_SCALE) + for x, y in xy] + + pat = Pattern(f'_wgbend-a{lattice_constant:g}l{mirror_periods}') + pat.addsp(pat_half, offset=(0, 0), rotation=0, mirrored=(False, False)) + pat.addsp(pat_half, offset=(0, 0), rotation=-2 * pi / 3, mirrored=(True, False)) + + + ports = { + 'left': Port((-lattice_constant * mirror_periods, 0), rotation=0, ptype=1), + 'right': Port((lattice_constant * mirror_periods / 2, + lattice_constant * mirror_periods * numpy.sqrt(3) / 2), rotation=pi * 4 / 3, ptype=1), + } + return Device(pat, ports) + + +def label_ports(device: Device, layer: layer_t = (3, 0)) -> Device: + for name, port in device.ports.items(): + angle_deg = numpy.rad2deg(port.rotation) + device.pattern.labels += [ + Label(string=f'{name} (angle {angle_deg:g})', layer=layer, offset=port.offset) + ] + return device + + +def main(): + hole_layer = (1, 2) + a = 512 + hole_pat = hole(layer=hole_layer) + wg0 = label_ports(waveguide(lattice_constant=a, hole=hole_pat, length=10, mirror_periods=5)) + wg1 = label_ports(waveguide(lattice_constant=a, hole=hole_pat, length=5, mirror_periods=5)) + bend0 = label_ports(bend(lattice_constant=a, hole=hole_pat, mirror_periods=5)) + l3cav = label_ports(perturbed_l3(lattice_constant=a, hole=hole_pat, xy_size=(4, 10))) + + dev = Device(name='my_bend', ports={}) + dev.place(wg0, offset=(0, 0), port_map={'left': 'in', 'right': 'signal'}) + dev.plug(wg0, {'signal': 'left'}) + dev.plug(bend0, {'signal': 'left'}) + dev.plug(wg1, {'signal': 'left'}) + dev.plug(bend0, {'signal': 'right'}) + dev.plug(wg0, {'signal': 'left'}) + dev.plug(l3cav, {'signal': 'input'}) + dev.plug(wg0, {'signal': 'left'}) + + writefile(dev.pattern, 'phc.gds', 1e-9, 1e-3) + dev.pattern.visualize() + + +if __name__ == '__main__': + main() 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..12d4cbb --- /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.4 +''' diff --git a/masque/__init__.py b/masque/__init__.py index 87ceb5d..611d4d4 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 @@ -15,9 +15,18 @@ Note that the methods for these classes try to avoid copying wherever possible, so unless otherwise noted, assume that arguments are stored by-reference. -""" -import pathlib + + 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 .shapes import Shape @@ -25,11 +34,10 @@ 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' -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/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..519462f --- /dev/null +++ b/masque/builder/devices.py @@ -0,0 +1,726 @@ +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 *= -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', '_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 *= -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) + 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 e109c20..84607b7 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,10 +19,22 @@ 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 """ 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/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/klamath.py b/masque/file/klamath.py index 0f858cf..e7015be 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. @@ -551,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 @@ -568,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. @@ -576,12 +583,22 @@ 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) + 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') @@ -597,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. @@ -615,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. 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/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 diff --git a/masque/pattern.py b/masque/pattern.py index 55c9ab7..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. @@ -91,7 +92,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): self.name = name self.set_locked(locked) - def __copy__(self: P, memo: Dict = None) -> P: + def __copy__(self, memo: Dict = None) -> 'Pattern': return Pattern(name=self.name, shapes=copy.deepcopy(self.shapes), labels=copy.deepcopy(self.labels), @@ -99,7 +100,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): annotations=copy.deepcopy(self.annotations), locked=self.locked) - def __deepcopy__(self: P, memo: Dict = None) -> P: + def __deepcopy__(self, memo: Dict = None) -> 'Pattern': memo = {} if memo is None else memo new = Pattern( name=self.name, @@ -130,12 +131,12 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): self.labels += other_pattern.labels return self - def subset(self: P, + def subset(self, shapes_func: Callable[[Shape], bool] = None, labels_func: Callable[[Label], bool] = None, subpatterns_func: Callable[[SubPattern], bool] = None, recursive: bool = False, - ) -> P: + ) -> 'Pattern': """ Returns a Pattern containing only the entities (e.g. shapes) for which the given entity_func returns True. @@ -155,7 +156,7 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): A Pattern containing all the shapes and subpatterns for which the parameter functions return True """ - def do_subset(src: Optional[P]) -> Optional[P]: + def do_subset(src: Optional['Pattern']) -> Optional['Pattern']: if src is None: return None pat = Pattern(name=src.name) @@ -175,10 +176,10 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): assert(pat is not None) return pat - def apply(self: P, - func: Callable[[Optional[P]], Optional[P]], - memo: Optional[Dict[int, Optional[P]]] = None, - ) -> Optional[P]: + def apply(self, + func: Callable[[Optional['Pattern']], Optional['Pattern']], + memo: Optional[Dict[int, Optional['Pattern']]] = None, + ) -> Optional['Pattern']: """ Recursively apply func() to this pattern and any pattern it references. func() is expected to take and return a Pattern. @@ -473,7 +474,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). @@ -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: @@ -803,14 +804,14 @@ class Pattern(LockableImpl, AnnotatableImpl, metaclass=AutoSlots): and len(self.shapes) == 0 and len(self.labels) == 0) - def addsp(self, *args: Dict[str, Any], **kwargs: Dict[str, Any]): + 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() + *args: Passed to `SubPattern()` + **kwargs: Passed to `SubPattern()` Returns: self @@ -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/shapes/arc.py b/masque/shapes/arc.py index 0416d01..f4542c4 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -236,12 +236,16 @@ class Arc(Shape, metaclass=AutoSlots): if poly_max_arclen is not None: n += [perimeter / poly_max_arclen] num_points = int(round(max(n))) - thetas_inner = numpy.linspace(a_ranges[0][1], a_ranges[0][0], num_points, endpoint=True) + + wh = self.width / 2.0 + if wh == r0 and r0 == r1: + thetas_inner = [0] # Don't generate multiple vertices if we're at the origin + else: + thetas_inner = numpy.linspace(a_ranges[0][1], a_ranges[0][0], num_points, endpoint=True) thetas_outer = numpy.linspace(a_ranges[1][0], a_ranges[1][1], num_points, endpoint=True) sin_th_i, cos_th_i = (numpy.sin(thetas_inner), numpy.cos(thetas_inner)) sin_th_o, cos_th_o = (numpy.sin(thetas_outer), numpy.cos(thetas_outer)) - wh = self.width / 2.0 xs1 = (r0 + wh) * cos_th_o ys1 = (r1 + wh) * sin_th_o @@ -326,6 +330,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': 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 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/snaps/snapper.py b/masque/snaps/snapper.py deleted file mode 100644 index 190ba55..0000000 --- a/masque/snaps/snapper.py +++ /dev/null @@ -1,121 +0,0 @@ - - -callback_t = Callable[[T, numpy.ndarray, Dict[str, int]], ...] - - -class Device: - pattern: Pattern - ports: numpy.ndarray - port_names: Dict[str, int] - callback: Optional[callback_t] = None - - def __init__(self, - pattern: Optional[Pattern] = None, - ports: Optional[numpy.ndarray] = None, - names: Optional[Union[Dict[str, int], List[Optional[str]]] = None, - callback: Optional[callback_t]= None - ) -> None: - if pattern is None: - self.pattern = Pattern() - - if ports is None: - self.ports = {0: [0, 0, 0, 0], - 1: [0, 0, pi, 0]} - else: - self.ports = numpy.array(ports) - - if callback: - self.callback = callback - self.callback(self, self.ports.keys()) - - def __getitem__(self, key) -> numpy.ndarray: - if isinstance(key, str): - inds = [self.port_names[key]] - elif hasattr(key, '__iter__'): - inds = [self.port_names.get(k, k) for k in key] - else: - inds = [self.port_names.get(key, key)] - return self.ports[inds] - - def build(self: T, - name: str, - other: Device, - map_in: Dict[port_t, port_t], - map_out: Dict[port_t, port_t], - mirror: bool = False, - ) -> T: - translation, rotation = self.find_transform(other, map_in, map_out, mirror) - - pat = Pattern(name) - pat.addsp(self.pattern) - new = Device(pat, ports=self.ports, port_names=self.port_names, callback=self.callback) - return new - - - def plug(self, other, map_in, map_out, mirror): - translation, rotation, pivot = self.find_transform(other, map_in, map_out, mirror) - - sp = SubPattern(other.pattern, mirrored=mirror) - sp.rotate_around(pivot, rotation) - sp.translate(translation) - self.pat.subpatterns.append(sp) - - # get rid of plugged ports - - # insert remaining device ports into router port list - - with numpy.errstate(invalid='ignore'): - self.ports[:, 2] %= 2 * pi - - if self.port_callback: - self.port_callback(...) - - def find_transform(self, other, map_in, map_out, mirror): - s_ports = self[map_in.keys()] - o_ports= other[map_in.values()] - - if mirror[0]: - o_ports[:, 1] *= -1 - o_ports[:, 2] += pi - if mirror[1]: - o_ports[:, 0] *= -1 - o_ports[:, 2] += pi - - s_offsets = s_ports[:, :2] - s_angles = s_ports[:, 2] - s_types = s_ports[:, 3] - o_offsets = o_ports[:, :2] - o_angles = o_ports[:, 2] - o_types = o_ports[:, 3] - - if (r_types != d_types) & (r_types != 0) & (d_types != 0): - #TODO warn about types - - rotations = numpy.mod(s_angles - o_angles - pi, 2 * pi) - - if not numpy.allclose(rotations[:1], rotations): - # TODO warn about rotation - - rot_ports = rotate_ports_around(o_ports, o_offsets[0], rotations[0]) #TODO also rotate unplugged device ports - - translations = r_offsets - d_offsets - translation = translations[0] - - if not numpy.allclose(translations[:1], translations): - - return translations[0], rotations[0] - - - def as_pattern(self, name) -> Pattern: - return self.pat.copy().rename(name) - - def as_device(self, name): - return Device(self.as_pattern, REMAINING_NON-NAN_PORTS) #TODO - - -def rotate_ports_around(ports: numpy.ndarray, pivot: numpy.ndarray, angle: float) -> numpy.ndarray: - ports[:, :2] -= pivot - ports[:, :2] = (rotation_matrix_2d(angle) @ ports[:, :2].T).T - ports[:, :2] += pivot - ports[:, 2] += angle - return ports diff --git a/masque/subpattern.py b/masque/subpattern.py index 9d04b89..3913b33 100644 --- a/masque/subpattern.py +++ b/masque/subpattern.py @@ -79,13 +79,13 @@ 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 {} self.set_locked(locked) - def __copy__(self: S) -> S: + def __copy__(self) -> 'SubPattern': new = SubPattern(pattern=self.pattern, offset=self.offset.copy(), rotation=self.rotation, @@ -97,7 +97,7 @@ class SubPattern(PositionableImpl, DoseableImpl, RotatableImpl, ScalableImpl, Mi locked=self.locked) return new - def __deepcopy__(self: S, memo: Dict = None) -> S: + def __deepcopy__(self, memo: Dict = None) -> 'SubPattern': memo = {} if memo is None else memo new = copy.copy(self).unlock() new.pattern = copy.deepcopy(self.pattern, memo) @@ -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/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..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): # """ @@ -50,7 +66,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..1e83697 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') @@ -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,8 +56,8 @@ class RotatableImpl(Rotatable, metaclass=ABCMeta): @rotation.setter def rotation(self, val: float): - if not is_scalar(val): - raise PatternError('Rotation must be a scalar') + if not numpy.size(val) == 1: + 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 ''' diff --git a/masque/utils.py b/masque/utils.py index a09a09c..9588ee6 100644 --- a/masque/utils.py +++ b/masque/utils.py @@ -120,12 +120,12 @@ def remove_colinear_vertices(vertices: numpy.ndarray, closed_path: bool = True) Returns: `vertices` with colinear (superflous) vertices removed. """ - vertices = numpy.array(vertices) + vertices = remove_duplicate_vertices(vertices) # Check for dx0/dy0 == dx1/dy1 dv = numpy.roll(vertices, -1, axis=0) - vertices # [y1-y0, y2-y1, ...] - dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dy0]] + dxdy = dv * numpy.roll(dv, 1, axis=0)[:, ::-1] # [[dx0*(dy_-1), (dx_-1)*dy0], dx1*dy0, dy1*dx0]] dxdy_diff = numpy.abs(numpy.diff(dxdy, axis=1))[:, 0] err_mult = 2 * numpy.abs(dxdy).sum(axis=1) + 1e-40 diff --git a/setup.py b/setup.py index ff8aad9..c9c91ed 100644 --- a/setup.py +++ b/setup.py @@ -3,11 +3,11 @@ 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', '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,16 +19,15 @@ setup(name='masque', url='https://mpxd.net/code/jan/masque', packages=find_packages(), package_data={ - 'masque': ['VERSION', - 'py.typed', + 'masque': ['py.typed', ] }, install_requires=[ - 'numpy', + 'numpy', ], extras_require={ 'gdsii': ['python-gdsii'], - 'klamath': ['klamath'], + 'klamath': ['klamath>=0.3'], 'oasis': ['fatamorgana>=0.7'], 'dxf': ['ezdxf'], 'svg': ['svgwrite'],