diff --git a/README.md b/README.md index 23743e3..68c0686 100644 --- a/README.md +++ b/README.md @@ -233,5 +233,3 @@ my_pattern.ref(_make_my_subpattern(), offset=..., ...) * Tests tests tests * check renderpather * pather and renderpather examples -* context manager for retool -* allow a specific mismatch when connecting ports diff --git a/examples/tutorial/pather.py b/examples/tutorial/pather.py index a7aad68..101fbb5 100644 --- a/examples/tutorial/pather.py +++ b/examples/tutorial/pather.py @@ -265,6 +265,12 @@ def main() -> None: # when using pather.retool(). pather.path_to('VCC', None, -50_000, out_ptype='m1wire') + # Now extend GND out to x=-50_000, using M2 for a portion of the path. + # We can use `pather.toolctx()` to temporarily retool, instead of calling `retool()` twice. + with pather.toolctx(M2_tool, keys=['GND']): + pather.path_to('GND', None, -40_000) + pather.path_to('GND', None, -50_000) + # Save the pather's pattern into our library library['Pather_and_BasicTool'] = pather.pattern diff --git a/masque/builder/builder.py b/masque/builder/builder.py index b92c9ac..fed839a 100644 --- a/masque/builder/builder.py +++ b/masque/builder/builder.py @@ -2,7 +2,7 @@ Simplified Pattern assembly (`Builder`) """ from typing import Self -from collections.abc import Sequence, Mapping +from collections.abc import Iterable, Sequence, Mapping import copy import logging from functools import wraps @@ -226,6 +226,7 @@ class Builder(PortList): inherit_name: bool = True, set_rotation: bool | None = None, append: bool = False, + ok_connections: Iterable[tuple[str, str]] = (), ) -> Self: """ Wrapper around `Pattern.plug` which allows a string for `other`. @@ -260,6 +261,11 @@ class Builder(PortList): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. Returns: self @@ -293,6 +299,7 @@ class Builder(PortList): inherit_name=inherit_name, set_rotation=set_rotation, append=append, + ok_connections=ok_connections, ) return self diff --git a/masque/builder/pather.py b/masque/builder/pather.py index 17d73b3..87f08a3 100644 --- a/masque/builder/pather.py +++ b/masque/builder/pather.py @@ -2,9 +2,10 @@ Manual wire/waveguide routing (`Pather`) """ from typing import Self -from collections.abc import Sequence, MutableMapping, Mapping +from collections.abc import Sequence, MutableMapping, Mapping, Iterator import copy import logging +from contextlib import contextmanager from pprint import pformat import numpy @@ -281,6 +282,37 @@ class Pather(Builder): self.tools[key] = tool return self + @contextmanager + def toolctx( + self, + tool: Tool, + keys: str | Sequence[str | None] | None = None, + ) -> Iterator[Self]: + """ + Context manager for temporarily `retool`-ing and reverting the `retool` + upon exiting the context. + + Args: + tool: The new `Tool` to use for the given ports. + keys: Which ports the tool should apply to. `None` indicates the default tool, + used when there is no matching entry in `self.tools` for the port in question. + + Returns: + self + """ + if keys is None or isinstance(keys, str): + keys = [keys] + saved_tools = {kk: self.tools.get(kk, None) for kk in keys} # If not in self.tools, save `None` + try: + yield self.retool(tool=tool, keys=keys) + finally: + for kk, tt in saved_tools.items(): + if tt is None: + # delete if present + self.tools.pop(kk, None) + else: + self.tools[kk] = tt + def path( self, portspec: str, diff --git a/masque/library.py b/masque/library.py index 82277e1..90202dc 100644 --- a/masque/library.py +++ b/masque/library.py @@ -542,7 +542,7 @@ class ILibraryView(Mapping[str, 'Pattern'], metaclass=ABCMeta): Return: Topologically sorted list of pattern names. """ - return list(TopologicalSorter(self.child_graph()).static_order()) + return cast(list[str], list(TopologicalSorter(self.child_graph()).static_order())) def find_refs_local( self, diff --git a/masque/pattern.py b/masque/pattern.py index 1816762..0ae230d 100644 --- a/masque/pattern.py +++ b/masque/pattern.py @@ -1225,6 +1225,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): inherit_name: bool = True, set_rotation: bool | None = None, append: bool = False, + ok_connections: Iterable[tuple[str, str]] = (), ) -> Self: """ Instantiate or append a pattern into the current pattern, connecting @@ -1270,6 +1271,11 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): append: If `True`, `other` is appended instead of being referenced. Note that this does not flatten `other`, so its refs will still be refs (now inside `self`). + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. Returns: self @@ -1300,6 +1306,7 @@ class Pattern(PortList, AnnotatableImpl, Mirrorable): map_in, mirrored=mirrored, set_rotation=set_rotation, + ok_connections=ok_connections, ) # get rid of plugged ports diff --git a/masque/ports.py b/masque/ports.py index 6f029b1..1cc711a 100644 --- a/masque/ports.py +++ b/masque/ports.py @@ -419,6 +419,7 @@ class PortList(metaclass=ABCMeta): *, mirrored: bool = False, set_rotation: bool | None = None, + ok_connections: Iterable[tuple[str, str]] = (), ) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]: """ Given a device `other` and a mapping `map_in` specifying port connections, @@ -435,6 +436,11 @@ class PortList(metaclass=ABCMeta): port with `rotation=None`), `set_rotation` must be provided to indicate how much `other` should be rotated. Otherwise, `set_rotation` must remain `None`. + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. Returns: - The (x, y) translation (performed last) @@ -451,6 +457,7 @@ class PortList(metaclass=ABCMeta): map_in=map_in, mirrored=mirrored, set_rotation=set_rotation, + ok_connections=ok_connections, ) @staticmethod @@ -461,13 +468,14 @@ class PortList(metaclass=ABCMeta): *, mirrored: bool = False, set_rotation: bool | None = None, + ok_connections: Iterable[tuple[str, str]] = (), ) -> tuple[NDArray[numpy.float64], float, NDArray[numpy.float64]]: """ Given two sets of ports (s_ports and o_ports) and a mapping `map_in` specifying port connections, find the transform which will correctly align the specified o_ports onto their respective s_ports. - Args:t + Args: s_ports: A list of stationary ports o_ports: A list of ports which are to be moved/mirrored. map_in: dict of `{'s_port': 'o_port'}` mappings, specifying @@ -479,6 +487,11 @@ class PortList(metaclass=ABCMeta): port with `rotation=None`), `set_rotation` must be provided to indicate how much `o_ports` should be rotated. Otherwise, `set_rotation` must remain `None`. + ok_connections: Set of "allowed" ptype combinations. Identical + ptypes are always allowed to connect, as is `'unk'` with + any other ptypte. Non-allowed ptype connections will emit a + warning. Order is ignored, i.e. `(a, b)` is equivalent to + `(b, a)`. Returns: - The (x, y) translation (performed last) @@ -502,7 +515,8 @@ class PortList(metaclass=ABCMeta): o_offsets[:, 1] *= -1 o_rotations *= -1 - type_conflicts = numpy.array([st != ot and 'unk' not in (st, ot) + ok_pairs = {tuple(sorted(pair)) for pair in ok_connections if pair[0] != pair[1]} + type_conflicts = numpy.array([(st != ot) and ('unk' not in (st, ot)) and (tuple(sorted((st, ot))) not in ok_pairs) for st, ot in zip(s_types, o_types, strict=True)]) if type_conflicts.any(): msg = 'Ports have conflicting types:\n' @@ -523,8 +537,8 @@ class PortList(metaclass=ABCMeta): if not numpy.allclose(rotations[:1], rotations): rot_deg = numpy.rad2deg(rotations) msg = 'Port orientations do not match:\n' - for nn, (k, v) in enumerate(map_in.items()): - msg += f'{k} | {rot_deg[nn]:g} | {v}\n' + for nn, (kk, vv) in enumerate(map_in.items()): + msg += f'{kk} | {rot_deg[nn]:g} | {vv}\n' raise PortError(msg) pivot = o_offsets[0].copy() @@ -532,8 +546,8 @@ class PortList(metaclass=ABCMeta): translations = s_offsets - o_offsets if not numpy.allclose(translations[:1], translations): msg = 'Port translations do not match:\n' - for nn, (k, v) in enumerate(map_in.items()): - msg += f'{k} | {translations[nn]} | {v}\n' + for nn, (kk, vv) in enumerate(map_in.items()): + msg += f'{kk} | {translations[nn]} | {vv}\n' raise PortError(msg) return translations[0], rotations[0], o_offsets[0] diff --git a/masque/shapes/arc.py b/masque/shapes/arc.py index da5d3f8..67d932d 100644 --- a/masque/shapes/arc.py +++ b/masque/shapes/arc.py @@ -233,7 +233,7 @@ class Arc(Shape): r0, r1 = self.radii # Convert from polar angle to ellipse parameter (for [rx*cos(t), ry*sin(t)] representation) - a_ranges = self._angles_to_parameters() + a_ranges = cast(tuple[tuple[float, float], tuple[float, float]], self._angles_to_parameters()) # Approximate perimeter via numerical integration diff --git a/masque/utils/curves.py b/masque/utils/curves.py new file mode 100644 index 0000000..b17b015 --- /dev/null +++ b/masque/utils/curves.py @@ -0,0 +1,95 @@ +import numpy +from numpy.typing import ArrayLike, NDArray +from numpy import pi + + +def bezier( + nodes: ArrayLike, + tt: ArrayLike, + weights: ArrayLike | None = None, + ) -> NDArray[numpy.float64]: + """ + Sample a Bezier curve with the provided control points at the parametrized positions `tt`. + + Using the calculation method from arXiv:1803.06843, Chudy and Woźny. + + Args: + nodes: `[[x0, y0], ...]` control points for the Bezier curve + tt: Parametrized positions at which to sample the curve (1D array with values in the interval [0, 1]) + weights: Control point weights; if provided, length should be the same as number of control points. + Default 1 for all control points. + + Returns: + `[[x0, y0], [x1, y1], ...]` corresponding to `[tt0, tt1, ...]` + """ + nn = nodes.shape[0] + if weights is None: + weights = numpy.ones(nn) + + t_half0 = tt <= 0.5 + umul = tt / (1 - tt) + udiv = 1 / umul + umul[~t_half0] = 1 + udiv[t_half0] = 1 + + hh = numpy.ones((tt.size, 1)) + qq = nodes[None, 0] * hh + for kk in range(1, nn): + hh *= umul * (nn + 1 - kk) * weights[kk] + hh /= kk * udiv * weights[kk - 1] + hh + qq *= 1.0 - hh + qq += hh * nodes[None, kk] + return qq + + + +def euler_bend(switchover_angle: float) -> NDArray[numpy.float64]: + """ + Generate a 90 degree Euler bend (AKA Clothoid bend or Cornu spiral). + + Args: + switchover_angle: After this angle, the bend will transition into a circular arc + (and transition back to an Euler spiral on the far side). If this is set to + `>= pi / 4`, no circular arc will be added. + + Returns: + `[[x0, y0], ...]` for the curve + """ + # Switchover angle + # AKA: Clothoid bend, Cornu spiral + theta_max = numpy.sqrt(2 * switchover_angle) + + def gen_curve(theta_max: float): + xx = [] + yy = [] + for theta in numpy.linspace(0, theta_max, 100): + qq = numpy.linspace(0, theta, 1000) + xx.append(numpy.trapz( numpy.cos(qq * qq / 2), qq)) + yy.append(numpy.trapz(-numpy.sin(qq * qq / 2), qq)) + xy_part = numpy.stack((xx, yy), axis=1) + return xy_part + + xy_part = gen_curve(theta_max) + xy_parts = [xy_part] + + if switchover_angle < pi / 4: + # Build a circular segment to join the two euler portions + rmin = 1.0 / theta_max + half_angle = pi / 4 - switchover_angle + qq = numpy.linspace(half_angle * 2, 0, 10) + switchover_angle + xc = rmin * numpy.cos(qq) + yc = rmin * numpy.sin(qq) + xy_part[-1, 1] + xc += xy_part[-1, 0] - xc[0] + yc += xy_part[-1, 1] - yc[0] + xy_parts.append(numpy.stack((xc, yc), axis=1)) + + endpoint_xy = xy_parts[-1][-1, :] + second_curve = xy_part[::-1, ::-1] + endpoint_xy - xy_part[-1, ::-1] + + xy_parts.append(second_curve) + xy = numpy.concatenate(xy_parts) + + # Remove any 2x-duplicate points + xy = xy[(numpy.roll(xy, 1, axis=0) != xy).any(axis=1)] + + return xy