from typing import Mapping, Sequence, SupportsFloat, cast, TYPE_CHECKING from pprint import pformat import numpy from numpy import pi from numpy.typing import ArrayLike, NDArray from ..utils import rotation_matrix_2d, SupportsBool from ..error import BuildError if TYPE_CHECKING: from ..ports import Port def ell( ports: Mapping[str, 'Port'], ccw: SupportsBool | None, bound_type: str, bound: float | ArrayLike, *, spacing: float | ArrayLike | None = None, set_rotation: float | None = 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', 'pmin', 'xmin', 'ymin': The coordinate of the innermost bend (D's bend). - 'max_position', 'pmax', 'xmax', 'ymax': 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): port_rotations = {k: numpy.rad2deg(p.rotation) if p.rotation is not None else None for k, p in ports.items()} raise BuildError('Asked to find aggregation for ports that face in different directions:\n' + pformat(port_rotations)) 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) is_horizontal = numpy.isclose(rotations[0] % pi, 0) if bound_type in ('ymin', 'ymax') and is_horizontal: raise BuildError(f'Asked for {bound_type} position but ports are pointing along the x-axis!') elif bound_type in ('xmin', 'xmax') and not is_horizontal: raise BuildError(f'Asked for {bound_type} position but ports are pointing along the y-axis!') direction = rotations[0] + pi # direction we want to travel in (+pi relative to port) rot_matrix = rotation_matrix_2d(-direction) # 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 # ordering_base = rot_offsets.T * [[1], [-1 if ccw else 1]] # could work, but this is actually a more complex routing problem # y_order = numpy.lexsort(ordering_base) # (need to make sure we don't collide with the next input port @ same y) y_order = ((-1 if ccw else 1) * rot_offsets[:, 1]).argsort(kind='stable') y_ind = numpy.empty_like(y_order, dtype=int) y_ind[y_order] = numpy.arange(y_ind.shape[0]) 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 offsets: NDArray[numpy.float64] 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) rot_bound: SupportsFloat if bound_type in ('emin', 'min_extension', 'emax', 'max_extension', 'min_past_furthest',): if numpy.size(bound) == 2: bound = cast(Sequence[float], bound) 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', 'xmax', 'ymax'): extension = rot_bound - min_possible.max() elif bound_type in ('pmin', 'min_position', 'xmin', 'ymin'): extension = rot_bound - min_possible.min() offsets += extension if extension < 0: ext_floor = -numpy.floor(extension) raise BuildError(f'Position is too close by at least {ext_floor}. Total extensions would be\n\t' + '\n\t'.join(f'{key}: {off}' for key, off in zip(ports.keys(), offsets))) result = dict(zip(ports.keys(), offsets)) return result