Source code for photonforge.utils

import warnings
from collections import defaultdict
from collections.abc import Sequence
from typing import Any, Literal

import numpy as np
import tidy3d as td

from .extension import (
    Circle,
    Component,
    Interpolator,
    MaskSpec,
    Path,
    Polygon,
    Port,
    PortSpec,
    Rectangle,
    Reference,
    SMatrix,
    Technology,
    _content_repr,
    _pack_rectangles,
    config,
    snap_to_grid,
)

# Speed of light in vacuum (in µm/s)
C_0: float = 2.99792458e14

# Elementary charge (in C)
Q = 1.602176634e-19

# Planck's constant (in Js)
H = 6.62607015e-34

# Boltzmann constant (in J/K)
K_B = 1.380649e-23


def _angles_equal(a: float, b: float) -> bool:
    r = (a - b) % 360
    return r <= 1e-12 or 360 - r <= 1e-12


def _gather_status(*runners: Any) -> dict[str, Any]:
    """Create an overall status based on a collection of runners."""
    num_tasks = 0
    progress = 0
    message = "success"
    tasks = {}
    for task in runners:
        task_status = (
            {"progress": 100, "message": "success"} if isinstance(task, SMatrix) else task.status
        )
        inner_tasks = task_status.get("tasks", {})
        tasks.update(inner_tasks)
        task_weight = max(1, len(inner_tasks))
        num_tasks += task_weight
        if message != "error":
            if task_status["message"] == "error":
                message = "error"
            elif task_status["message"] == "running":
                message = "running"
                progress += task_weight * task_status["progress"]
            elif task_status["message"] == "success":
                progress += task_weight * 100
    if message == "running":
        progress /= num_tasks
    else:
        progress = 100
    return {"progress": progress, "message": message, "tasks": tasks}


[docs] def route_length( component: Component, layer: Sequence[int] | None = None, port_spec: PortSpec | None = None ) -> float: """Measure the length of parametric routes. Internally, this functions adds the path lengths (without offsets) for all paths with distinct endpoints in a specific layer. Args: component: Component with routes to be measured. layer: Layer to be used to search for paths. If ``None``, a best guess based on ``port_spec`` will be used. port_spec: Port specification used for the route. If ``None``, the component will be inspected and a best guess used. Returns: Total path length. See also: `Parametric routes <../parametric.rst#routing>`__ """ if layer is None: if port_spec is None: ports = tuple(p for p in component.ports.values() if isinstance(p, Port)) opt_ports = tuple(p for p in ports if p.classification == "optical") elec_ports = tuple(p for p in ports if p.classification == "electrical") if len(opt_ports) == 2: port_spec = opt_ports[0].spec elif len(elec_ports) == 2: port_spec = elec_ports[0].spec if port_spec is not None: profiles = sorted(port_spec.path_profiles_list(), key=lambda p: (abs(p[1]), p[0], p[2])) if len(profiles) > 0: layer = profiles[0][2] structures = component.get_structures(layer) if layer is not None: structures = {(0, 0): structures} result = 0.0 for structure_list in structures.values(): paths = defaultdict(list) for path in structure_list: if isinstance(path, Path): key = tuple( sorted( tuple(snap_to_grid(pos)) for pos in (path.origin, path.at(path.size, output="position")) ) ) paths[key].append(path) if len(paths) > 0: result = max( result, sum( sum(path.length(include_offset=False) for path in path_list) / len(path_list) for path_list in paths.values() ), ) return result
def _layer_in_mask_score(layer: tuple[int, int], mask: MaskSpec) -> int: if mask.layer is not None: return 1 if mask.layer == layer else None operands = mask.operand1 + mask.operand2 if mask.operation == "+": for inner in operands: score = _layer_in_mask_score(layer, inner) if score is not None: return 10 * score + len(operands) elif mask.operation == "*": for inner in operands: score = _layer_in_mask_score(layer, inner) if score is not None: return 20 * score + len(operands) elif mask.operation == "-": for inner in mask.operand1: score = _layer_in_mask_score(layer, inner) if score is not None: return 20 * score + len(operands) return None _virtual_port_specs = {}
[docs] def virtual_port_spec( num_modes: int = 1, classification: str = "optical", impedance: complex | Interpolator = 50 ) -> PortSpec: """Template to generate a virtual PortSpec. Virtual port specs have no path profiles and can be used to help with schematic-driven design before any layout is created. Args: num_modes: Number of modes supported by the port. classification: One of ``"optical"`` or ``"electrical"``. impedance: Complex impedance as a single or frequency-dependent interpolated value (in ohms). Returns: Virtual port specification with no path profiles. """ virtual = None if classification == "optical": virtual = PortSpec("Virtual spec (optical)", 1, (0, 0), num_modes) elif classification == "electrical": virtual = PortSpec("Virtual spec (electrical)", 1, (0, 0), num_modes, impedance=impedance) key = _content_repr(classification, num_modes, impedance, include_config=False) cached = _virtual_port_specs.get(key) if cached != virtual: _virtual_port_specs[key] = virtual cached = virtual return cached
[docs] def cpw_spec( layer: str | Sequence[int], signal_width: float, gap: float, ground_width: float | None = None, description: str | None = None, width: float | None = None, limits: Sequence[float] | None = None, num_modes: int = 1, added_solver_modes: int = 0, target_neff: float = 4.0, gap_layer: None | str | Sequence[int] | None = None, include_ground: bool = True, conductor_limits: Sequence[float] | None = None, technology: Technology | None = None, ) -> PortSpec: """Template to generate a coplanar transmission line PortSpec. Args: layer: Layer used for the transmission line layout. signal_width: Width of the central conductor. gap: Distance between the central conductor and the grounds. ground_width: Width of the ground conductors. description: Description used in :attr:`PortSpec.description`. width: Dimension used in :attr:`PortSpec.width`. limits: Vertical port limits used in :attr:`PortSpec.limits`. num_modes: Value used for :attr:`PortSpec.num_modes`. added_solver_modes: Value used for :attr:`PortSpec.added_solver_modes`. target_neff: Value used for :attr:`PortSpec.target_neff`. gap_layer: If set, path profiles for the gap region are included in this layer. include_ground: If ``False``, ground path profiles are not included. conductor_limits: Lower and upper bounds of the conductor layer extrusion. technology: Technology in use. If ``None``, the default is used. Returns: PortSpec for the CPW transmission line. Note: If ``conductor_limits`` is not given, the extrusion specifications in ``technology`` are inspected. If an specification for the selected ``layer`` is found, its extrusion limits are used. """ if technology is None: technology = config.default_technology if isinstance(layer, str): layer = technology.layers[layer].layer if isinstance(gap_layer, str): gap_layer = technology.layers[gap_layer].layer if conductor_limits is None: best_score = 1e30 for extrusion in technology.extrusion_specs: medium = extrusion.get_medium("electrical") if not (medium.is_pec or isinstance(medium, td.LossyMetalMedium)): continue if extrusion.mask_spec.layer == layer: conductor_limits = extrusion.limits break score = _layer_in_mask_score(layer, extrusion.mask_spec) if score is not None and score < best_score: conductor_limits = extrusion.limits best_score = score if conductor_limits is None: raise RuntimeError( f"Unable to find a conductor extrusion specification for layer {layer}. Please " f"specify 'conductor_limits' manually." ) z_center = 0.5 * (conductor_limits[0] + conductor_limits[1]) z_thickness = abs(conductor_limits[1] - conductor_limits[0]) cpw_min = min(signal_width, gap, z_thickness) # Scale found manually by testing a range of configurations cpw_scale = gap**0.3 * signal_width**0.6 ground_factor = 10 z_factor = 12 if ground_width is None: ground_width = ground_factor * cpw_scale offset = (signal_width + ground_width) / 2 + gap full_width = signal_width + 2 * gap + 2 * ground_width if description is None: description = f"CPW (signal width: {signal_width}, gap: {gap})" if width is None: width = min(full_width, signal_width + 2 * (gap + ground_factor * cpw_scale)) - cpw_min elif width >= full_width: warnings.warn( "CPW width is larger than the ground conductor extension. Please increase " "'ground_width' or decrease 'width', otherwise check the port modes to " "make sure the mode solver finds the correct modes.", stacklevel=2, ) if limits is None: z_margin = z_thickness / 2 + z_factor * cpw_scale limits = (z_center - z_margin, z_center + z_margin) path_profiles = {"signal": (signal_width, 0, layer)} if include_ground: path_profiles["gnd0"] = (ground_width, -offset, layer) path_profiles["gnd1"] = (ground_width, offset, layer) if gap_layer is not None: gap_offset = (signal_width + gap) / 2 path_profiles["gap0"] = (gap, -gap_offset, gap_layer) path_profiles["gap1"] = (gap, gap_offset, gap_layer) return PortSpec( description=description, width=width, limits=limits, num_modes=num_modes, added_solver_modes=added_solver_modes, target_neff=target_neff, path_profiles=path_profiles, voltage_path=[(signal_width / 2 + gap, z_center), (signal_width / 2, z_center)], current_path=Rectangle(center=(0, z_center), size=(signal_width + gap, z_thickness + gap)), )
[docs] def grid_layout( objects: Sequence[Component | Reference | Circle | Path | Polygon | Rectangle], gap: float | Sequence[float] = 0, shape: Sequence[int] | None = None, align_x: Literal["left", "right", "center", "origin"] | None = "center", align_y: Literal["bottom", "top", "center", "origin"] | None = "center", direction: Literal[ "lr-bt", "lr-tb", "rl-bt", "rl-tb", "bt-lr", "tb-lr", "bt-rl", "tb-rl" ] = "lr-bt", include_ports: bool = True, layer: tuple[int] = (0, 0), name: str | None = None, ) -> Component: """ Arrange components or other structures in a grid layout. Args: objects: Sequence of objects to arrange. They can be instances of :class:`Component`, :class:`Reference`, or 2D structures. gap: Horizontal and vertical gaps added between objects. shape: Grid shape, specified as ``(columns, rows)``. align_x: Horizontal alignment within the grid cell. align_y: Vertical alignment within the grid cell. direction: Placement order in the grid. Must be a combination of ``"lr"`` (left-to-right) or ``"rl"`` (right-to-left), and ``"bt"`` (bottom-to-top) or ``"tb"`` (top-to-bottom), as in ``"lr-bt"``, ``"rl-tb"``, ``"tb-lr"``, etc. include_ports: Whether or not to include ports when computing component bounds. layer: If arraging geometrical structures, add them to this layer. name: Name of the resulting component. Returns: Component with the objects arranged in a grid. """ num_objects = len(objects) if num_objects == 0: raise RuntimeError("List of objects cannot be empty.") directions = {"rl-bt", "rl-tb", "lr-bt", "lr-tb", "bt-rl", "tb-rl", "bt-lr", "tb-lr"} if direction not in directions: alternatives = ", ".join(repr(d) for d in sorted(directions)) raise ValueError(f"Invalid value for 'direction'. Must be one of {alternatives}") rows = int(num_objects**0.5 + 0.5) if shape is None else shape[1] cols = (num_objects + rows - 1) // rows if shape is None else shape[0] if num_objects > rows * cols: raise ValueError("More components than available grid slots.") if name is None: name = f"GRID_{cols}_{rows}" bounds = np.array( [ obj.bounds(include_ports) if isinstance(obj, Component) else obj.bounds() for obj in objects ] ) size = (bounds[:, 1, :] - bounds[:, 0, :]).max(axis=0) if align_x == "origin": size[0] = bounds[:, 1, 0].max() - bounds[:, 0, 0].min() if align_y == "origin": size[1] = bounds[:, 1, 1].max() - bounds[:, 0, 1].min() size += gap x_offsets = [col * size[0] for col in range(cols)] y_offsets = [row * size[1] for row in range(rows)] if direction[:2] == "rl" or direction[3:] == "rl": x_offsets.reverse() if direction[:2] == "tb" or direction[3:] == "tb": y_offsets.reverse() if "r" in direction[:2]: offsets = ((x, y) for y in y_offsets for x in x_offsets) else: offsets = ((x, y) for x in x_offsets for y in y_offsets) offsets = np.array(tuple(offsets)[:num_objects]) if align_x == "left": offsets[:, 0] -= bounds[:, 0, 0] elif align_x == "right": offsets[:, 0] -= bounds[:, 1, 0] elif align_x == "center": offsets[:, 0] -= bounds[:, :, 0].sum(axis=1) / 2 if align_y == "bottom": offsets[:, 1] -= bounds[:, 0, 1] elif align_y == "top": offsets[:, 1] -= bounds[:, 1, 1] elif align_y == "center": offsets[:, 1] -= bounds[:, :, 1].sum(axis=1) / 2 technology = None for i in range(len(objects)): if isinstance(objects[i], Component): objects[i] = Reference(objects[i]) if technology is None and isinstance(objects[i], Reference): technology = objects[i].component.technology objects[i].translate(offsets[i]) c = Component(name, technology) c.add(layer, *objects) return c
[docs] def pack_layout( objects: Sequence[Component | Reference | Circle | Path | Polygon | Rectangle], gap: float | Sequence[float] = 0, max_size: Sequence[float] = (0, 0), aspect_ratio: float = 0, grow_factor: float = 1.1, sorting: Literal["best", "area"] | None = "best", allow_rotation: bool = False, method: Literal["bl", "blsf", "bssf", "baf", "cp"] = "blsf", include_ports: bool = True, layer: tuple[int] = (0, 0), name: str = "PACK_{i}", ) -> list[Component]: """ Arrange components or other structures in a grid layout. Args: objects: Sequence of objects to arrange. They can be instances of :class:`Component`, :class:`Reference`, or 2D structures. gap: Horizontal and vertical gaps added between objects. max_size: Maximal size of the packed component. If not all objects fit in a single pack, multiple are used. aspect_ratio: Desired width:height ratio for the pack. grow_factor: Controls pack size increment. Values closer to 1 can result in tighter packs at the cost of more computation. sorting: Sorting option for the list of objects. If ``None``, objects are packed in the order they are listed; ``'area'`` will pack from largest to smallest, and ``"best"`` will try to choose the best object to pack at each iteration. allow_rotation: If ``True``, objects may be rotated by 90°. method: Heuristic used to select a free slot during packing. See below for information about the options. include_ports: Whether or not to include ports when computing component bounds. layer: If arranging geometrical structures, add them to this layer. name: Name template for the resulting components. Variable ``i`` is used to indicate the pack index in the case of multiple packs. Returns: List of components with the packed objects. Note: The available methods for selecting a free slot for packing are: Bottom left rule (``"bl"``): Use the left-most position among the lowest upper y-value options. Best long side fit (``"blsf"``): Use the position that minimizes the leftover length on the long side. Best short side fit (``"bssf"``): Use the position that minimizes the leftover length on the short side. Best area fit (``"baf"``): Use the smallest available area that fits. Contact point rule (``"cp"``): Use the position that maximizes the length of the perimeter that touches other objects. Reference: Jukka Jylänki, *A Thousand Ways to Pack the Bin – A Practical Approach to Two-Dimensional Rectangle Bin Packing*, 2010. """ if len(objects) == 0: raise RuntimeError("List of objects cannot be empty.") technology = None for i in range(len(objects)): if isinstance(objects[i], Component): technology = objects[i].technology break if isinstance(objects[i], Reference): technology = objects[i].component.technology break bounds = np.array( [ obj.bounds(include_ports) if isinstance(obj, Component) else obj.bounds() for obj in objects ] ) sizes = bounds[:, 1, :] - bounds[:, 0, :] + gap keep_order = sorting != "best" if sorting == "area": order = sorted(((a * b, i) for i, (a, b) in enumerate(sizes)), reverse=True) objects = [objects[i] for _, i in order] sizes = [sizes[i] for _, i in order] else: objects = list(objects) sizes = list(sizes) for i in range(2): if max_size[i] > 0 and any(size[i] > max_size[i] for size in sizes): for j in range(len(objects)): if sizes[j][i] > max_size[i]: raise RuntimeError( f"{('Width', 'Height')[i]} of 'objects[{j}]' (plus gap) is larger than " f"'max_size[{i}]' ({sizes[j][i]} > {max_size[i]})." ) packs = [] while len(objects) > 0: pack = _pack_rectangles( sizes, method, max_size, aspect_ratio, grow_factor, keep_order, allow_rotation ) if len(pack) == 0: raise RuntimeError("Unable to pack objects.") packed_objects = [] for index, corner, rotate in pack: obj = objects[index] objects[index] = None sizes[index] = None if isinstance(obj, Component): obj = Reference(obj) if rotate: obj.rotate(90) xy_min, _ = obj.bounds() obj.translate(corner - xy_min) packed_objects.append(obj) c = Component(name.format(i=len(packs)), technology) c.add(layer, *packed_objects) packs.append(c) objects = [obj for obj in objects if obj is not None] sizes = [size for size in sizes if size is not None] return packs