Source code for tidy3d.plugins.microwave.path_integrals

"""Helper classes for performing path integrals with fields on the Yee grid"""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Optional, Union

import numpy as np
import pydantic.v1 as pd
import xarray as xr

from tidy3d.components.base import Tidy3dBaseModel, cached_property
from tidy3d.components.data.data_array import (
    FreqDataArray,
    FreqModeDataArray,
    ScalarFieldDataArray,
    ScalarFieldTimeDataArray,
    ScalarModeFieldDataArray,
    TimeDataArray,
)
from tidy3d.components.data.monitor_data import FieldData, FieldTimeData, ModeData, ModeSolverData
from tidy3d.components.geometry.base import Box, Geometry
from tidy3d.components.types import Ax, Axis, Coordinate2D, Direction
from tidy3d.components.validators import assert_line, assert_plane
from tidy3d.components.viz import add_ax_if_none
from tidy3d.constants import AMP, VOLT, fp_eps
from tidy3d.exceptions import DataError, Tidy3dError
from tidy3d.log import log

from .viz import (
    ARROW_CURRENT,
    plot_params_current_path,
    plot_params_voltage_minus,
    plot_params_voltage_path,
    plot_params_voltage_plus,
)

MonitorDataTypes = Union[FieldData, FieldTimeData, ModeData, ModeSolverData]
EMScalarFieldType = Union[ScalarFieldDataArray, ScalarFieldTimeDataArray, ScalarModeFieldDataArray]
IntegralResultTypes = Union[FreqDataArray, FreqModeDataArray, TimeDataArray]


class AbstractAxesRH(Tidy3dBaseModel, ABC):
    """Represents an axis-aligned right-handed coordinate system with one axis preferred.
    Typically `main_axis` would refer to the normal axis of a plane.
    """

    @cached_property
    @abstractmethod
    def main_axis(self) -> Axis:
        """Get the preferred axis."""

    @cached_property
    def remaining_axes(self) -> tuple[Axis, Axis]:
        """Get in-plane axes, ordered to maintain a right-handed coordinate system."""
        axes: list[Axis] = [0, 1, 2]
        axes.pop(self.main_axis)
        if self.main_axis == 1:
            return (axes[1], axes[0])
        return (axes[0], axes[1])

    @cached_property
    def remaining_dims(self) -> tuple[str, str]:
        """Get in-plane dimensions, ordered to maintain a right-handed coordinate system."""
        dim1 = "xyz"[self.remaining_axes[0]]
        dim2 = "xyz"[self.remaining_axes[1]]
        return (dim1, dim2)

    @cached_property
    def local_dims(self) -> tuple[str, str, str]:
        """Get in-plane dimensions with in-plane dims first, followed by the `main_axis` dimension."""
        dim3 = "xyz"[self.main_axis]
        return self.remaining_dims + tuple(dim3)

    @pd.root_validator(pre=False)
    def _warn_rf_license(cls, values):
        log.warning(
            "ℹ️ ⚠️ RF simulations are subject to new license requirements in the future. You have instantiated at least one RF-specific component.",
            log_once=True,
        )
        return values


[docs] class AxisAlignedPathIntegral(AbstractAxesRH, Box): """Class for defining the simplest type of path integral, which is aligned with Cartesian axes.""" _line_validator = assert_line() extrapolate_to_endpoints: bool = pd.Field( False, title="Extrapolate to Endpoints", description="If the endpoints of the path integral terminate at or near a material interface, " "the field is likely discontinuous. When this field is ``True``, fields that are outside and on the bounds " "of the integral are ignored. Should be enabled when computing voltage between two conductors.", ) snap_path_to_grid: bool = pd.Field( False, title="Snap Path to Grid", description="It might be desireable to integrate exactly along the Yee grid associated with " "a field. When this field is ``True``, the integration path will be snapped to the grid.", )
[docs] def compute_integral(self, scalar_field: EMScalarFieldType) -> IntegralResultTypes: """Computes the defined integral given the input ``scalar_field``.""" if not scalar_field.does_cover(self.bounds, fp_eps, np.finfo(np.float32).smallest_normal): raise DataError("Scalar field does not cover the integration domain.") coord = "xyz"[self.main_axis] scalar_field = self._get_field_along_path(scalar_field) # Get the boundaries min_bound = self.bounds[0][self.main_axis] max_bound = self.bounds[1][self.main_axis] if self.extrapolate_to_endpoints: # Remove field outside the boundaries scalar_field = scalar_field.sel({coord: slice(min_bound, max_bound)}) # Ignore values on the boundary (sel is inclusive) scalar_field = scalar_field.drop_sel({coord: (min_bound, max_bound)}, errors="ignore") coordinates = scalar_field.coords[coord].values else: coordinates = scalar_field.coords[coord].sel({coord: slice(min_bound, max_bound)}) # Integration is along the original coordinates plus ensure that # endpoints corresponding to the precise bounds of the port are included coords_interp = np.array([min_bound]) coords_interp = np.concatenate((coords_interp, coordinates)) coords_interp = np.concatenate((coords_interp, [max_bound])) coords_interp = {coord: coords_interp} # Use extrapolation for the 2 additional endpoints, unless there is only a single sample point method = "linear" if len(coordinates) == 1 and self.extrapolate_to_endpoints: method = "nearest" scalar_field = scalar_field.interp( coords_interp, method=method, kwargs={"fill_value": "extrapolate"} ) result = scalar_field.integrate(coord=coord) return self._make_result_data_array(result)
def _get_field_along_path(self, scalar_field: EMScalarFieldType) -> EMScalarFieldType: """Returns a selection of the input ``scalar_field`` ready for integration.""" (axis1, axis2) = self.remaining_axes (coord1, coord2) = self.remaining_dims if self.snap_path_to_grid: # Coordinates that are not integrated remaining_coords = { coord1: self.center[axis1], coord2: self.center[axis2], } # Select field nearest to center of integration line scalar_field = scalar_field.sel( remaining_coords, method="nearest", drop=False, ) else: # Try to interpolate unless there is only a single coordinate coord1dict = {coord1: self.center[axis1]} if scalar_field.sizes[coord1] == 1: scalar_field = scalar_field.sel(coord1dict, method="nearest") else: scalar_field = scalar_field.interp( coord1dict, method="linear", kwargs={"bounds_error": True} ) coord2dict = {coord2: self.center[axis2]} if scalar_field.sizes[coord2] == 1: scalar_field = scalar_field.sel(coord2dict, method="nearest") else: scalar_field = scalar_field.interp( coord2dict, method="linear", kwargs={"bounds_error": True} ) # Remove unneeded coordinates scalar_field = scalar_field.reset_coords(drop=True) return scalar_field @cached_property def main_axis(self) -> Axis: """Axis for performing integration.""" for index, value in enumerate(self.size): if value != 0: return index raise Tidy3dError("Failed to identify axis.") def _vertices_2D(self, axis: Axis) -> tuple[Coordinate2D, Coordinate2D]: """Returns the two vertices of this path in the plane defined by ``axis``.""" min = self.bounds[0] max = self.bounds[1] _, min = Box.pop_axis(min, axis) _, max = Box.pop_axis(max, axis) u = [min[0], max[0]] v = [min[1], max[1]] return (u, v) @staticmethod def _check_monitor_data_supported(em_field: MonitorDataTypes): """Helper for validating that monitor data is supported.""" if not isinstance(em_field, (FieldData, FieldTimeData, ModeData, ModeSolverData)): supported_types = list(MonitorDataTypes.__args__) raise DataError( f"'em_field' type {type(em_field)} not supported. Supported types are " f"{supported_types}" ) @staticmethod def _make_result_data_array(result: xr.DataArray) -> IntegralResultTypes: """Helper for creating the proper result type.""" if "t" in result.coords: return TimeDataArray(data=result.data, coords=result.coords) if "f" in result.coords and "mode_index" in result.coords: return FreqModeDataArray(data=result.data, coords=result.coords) return FreqDataArray(data=result.data, coords=result.coords)
[docs] class VoltageIntegralAxisAligned(AxisAlignedPathIntegral): """Class for computing the voltage between two points defined by an axis-aligned line.""" sign: Direction = pd.Field( ..., title="Direction of Path Integral", description="Positive indicates V=Vb-Va where position b has a larger coordinate along the axis of integration.", )
[docs] def compute_voltage(self, em_field: MonitorDataTypes) -> IntegralResultTypes: """Compute voltage along path defined by a line.""" self._check_monitor_data_supported(em_field=em_field) e_component = "xyz"[self.main_axis] field_name = f"E{e_component}" # Validate that fields are present em_field._check_fields_stored([field_name]) e_field = em_field.field_components[field_name] voltage = self.compute_integral(e_field) if self.sign == "+": voltage *= -1 voltage = VoltageIntegralAxisAligned._set_data_array_attributes(voltage) # Return data array of voltage while keeping coordinates of frequency|time|mode index return voltage
@staticmethod def _set_data_array_attributes(data_array: IntegralResultTypes) -> IntegralResultTypes: """Add explanatory attributes to the data array.""" data_array.name = "V" return data_array.assign_attrs(units=VOLT, long_name="voltage")
[docs] @staticmethod def from_terminal_positions( plus_terminal: float, minus_terminal: float, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None, extrapolate_to_endpoints: bool = True, snap_path_to_grid: bool = True, ) -> VoltageIntegralAxisAligned: """Helper to create a :class:`VoltageIntegralAxisAligned` from two coordinates that define a line and two positions indicating the endpoints of the path integral. Parameters ---------- plus_terminal : float Position along the voltage axis of the positive terminal. minus_terminal : float Position along the voltage axis of the negative terminal. x : float = None Position in x direction, only two of x,y,z can be specified to define line. y : float = None Position in y direction, only two of x,y,z can be specified to define line. z : float = None Position in z direction, only two of x,y,z can be specified to define line. extrapolate_to_endpoints: bool = True Passed directly to :class:`VoltageIntegralAxisAligned` snap_path_to_grid: bool = True Passed directly to :class:`VoltageIntegralAxisAligned` Returns ------- VoltageIntegralAxisAligned The created path integral for computing voltage between the two terminals. """ axis_positions = Geometry.parse_two_xyz_kwargs(x=x, y=y, z=z) # Calculate center and size of the future box midpoint = (plus_terminal + minus_terminal) / 2 length = np.abs(plus_terminal - minus_terminal) center = [midpoint, midpoint, midpoint] size = [length, length, length] for axis, position in axis_positions: size[axis] = 0 center[axis] = position direction = "+" if plus_terminal < minus_terminal: direction = "-" return VoltageIntegralAxisAligned( center=center, size=size, extrapolate_to_endpoints=extrapolate_to_endpoints, snap_path_to_grid=snap_path_to_grid, sign=direction, )
[docs] @add_ax_if_none def plot( self, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None, ax: Ax = None, **path_kwargs, ) -> Ax: """Plot path integral at single (x,y,z) coordinate. Parameters ---------- x : float = None Position of plane in x direction, only one of x,y,z can be specified to define plane. y : float = None Position of plane in y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z direction, only one of x,y,z can be specified to define plane. ax : matplotlib.axes._subplots.Axes = None Matplotlib axes to plot on, if not specified, one is created. **path_kwargs Optional keyword arguments passed to the matplotlib plotting of the line. For details on accepted values, refer to `Matplotlib's documentation <https://tinyurl.com/36marrat>`_. Returns ------- matplotlib.axes._subplots.Axes The supplied or created matplotlib axes. """ axis, position = self.parse_xyz_kwargs(x=x, y=y, z=z) if axis == self.main_axis or not np.isclose(position, self.center[axis], rtol=fp_eps): return ax (xs, ys) = self._vertices_2D(axis) # Plot the path plot_params = plot_params_voltage_path.include_kwargs(**path_kwargs) plot_kwargs = plot_params.to_kwargs() ax.plot(xs, ys, markevery=[0, -1], **plot_kwargs) # Plot special end points end_kwargs = plot_params_voltage_plus.include_kwargs(**path_kwargs).to_kwargs() start_kwargs = plot_params_voltage_minus.include_kwargs(**path_kwargs).to_kwargs() if self.sign == "-": start_kwargs, end_kwargs = end_kwargs, start_kwargs ax.plot(xs[0], ys[0], **start_kwargs) ax.plot(xs[1], ys[1], **end_kwargs) return ax
[docs] class CurrentIntegralAxisAligned(AbstractAxesRH, Box): """Class for computing conduction current via Ampère's circuital law on an axis-aligned loop.""" _plane_validator = assert_plane() sign: Direction = pd.Field( ..., title="Direction of Contour Integral", description="Positive indicates current flowing in the positive normal axis direction.", ) extrapolate_to_endpoints: bool = pd.Field( False, title="Extrapolate to Endpoints", description="This parameter is passed to :class:`AxisAlignedPathIntegral` objects when computing the contour integral.", ) snap_contour_to_grid: bool = pd.Field( False, title="Snap Contour to Grid", description="This parameter is passed to :class:`AxisAlignedPathIntegral` objects when computing the contour integral.", )
[docs] def compute_current(self, em_field: MonitorDataTypes) -> IntegralResultTypes: """Compute current flowing in loop defined by the outer edge of a rectangle.""" AxisAlignedPathIntegral._check_monitor_data_supported(em_field=em_field) ax1 = self.remaining_axes[0] ax2 = self.remaining_axes[1] h_component = "xyz"[ax1] v_component = "xyz"[ax2] h_field_name = f"H{h_component}" v_field_name = f"H{v_component}" # Validate that fields are present em_field._check_fields_stored([h_field_name, v_field_name]) h_horizontal = em_field.field_components[h_field_name] h_vertical = em_field.field_components[v_field_name] # Decompose contour into path integrals (bottom, right, top, left) = self._to_path_integrals(h_horizontal, h_vertical) current = 0 # Compute and add contributions from each part of the contour current += bottom.compute_integral(h_horizontal) current += right.compute_integral(h_vertical) current -= top.compute_integral(h_horizontal) current -= left.compute_integral(h_vertical) if self.sign == "-": current *= -1 current = CurrentIntegralAxisAligned._set_data_array_attributes(current) return current
@cached_property def main_axis(self) -> Axis: """Axis normal to loop""" for index, value in enumerate(self.size): if value == 0: return index raise Tidy3dError("Failed to identify axis.") def _to_path_integrals( self, h_horizontal=None, h_vertical=None ) -> tuple[AxisAlignedPathIntegral, ...]: """Returns four ``AxisAlignedPathIntegral`` instances, which represent a contour integral around the surface defined by ``self.size``.""" ax1 = self.remaining_axes[0] ax2 = self.remaining_axes[1] horizontal_passed = h_horizontal is not None vertical_passed = h_vertical is not None if self.snap_contour_to_grid and horizontal_passed and vertical_passed: (coord1, coord2) = self.remaining_dims # Locations where horizontal paths will be snapped v_bounds = [ self.center[ax2] - self.size[ax2] / 2, self.center[ax2] + self.size[ax2] / 2, ] h_snaps = h_horizontal.sel({coord2: v_bounds}, method="nearest").coords[coord2].values # Locations where vertical paths will be snapped h_bounds = [ self.center[ax1] - self.size[ax1] / 2, self.center[ax1] + self.size[ax1] / 2, ] v_snaps = h_vertical.sel({coord1: h_bounds}, method="nearest").coords[coord1].values bottom_bound = h_snaps[0] top_bound = h_snaps[1] left_bound = v_snaps[0] right_bound = v_snaps[1] else: bottom_bound = self.bounds[0][ax2] top_bound = self.bounds[1][ax2] left_bound = self.bounds[0][ax1] right_bound = self.bounds[1][ax1] # Horizontal paths path_size = list(self.size) path_size[ax1] = right_bound - left_bound path_size[ax2] = 0 path_center = list(self.center) path_center[ax2] = bottom_bound bottom = AxisAlignedPathIntegral( center=path_center, size=path_size, extrapolate_to_endpoints=self.extrapolate_to_endpoints, snap_path_to_grid=self.snap_contour_to_grid, ) path_center[ax2] = top_bound top = AxisAlignedPathIntegral( center=path_center, size=path_size, extrapolate_to_endpoints=self.extrapolate_to_endpoints, snap_path_to_grid=self.snap_contour_to_grid, ) # Vertical paths path_size = list(self.size) path_size[ax1] = 0 path_size[ax2] = top_bound - bottom_bound path_center = list(self.center) path_center[ax1] = left_bound left = AxisAlignedPathIntegral( center=path_center, size=path_size, extrapolate_to_endpoints=self.extrapolate_to_endpoints, snap_path_to_grid=self.snap_contour_to_grid, ) path_center[ax1] = right_bound right = AxisAlignedPathIntegral( center=path_center, size=path_size, extrapolate_to_endpoints=self.extrapolate_to_endpoints, snap_path_to_grid=self.snap_contour_to_grid, ) return (bottom, right, top, left) @staticmethod def _set_data_array_attributes(data_array: IntegralResultTypes) -> IntegralResultTypes: """Add explanatory attributes to the data array.""" data_array.name = "I" return data_array.assign_attrs(units=AMP, long_name="current")
[docs] @add_ax_if_none def plot( self, x: Optional[float] = None, y: Optional[float] = None, z: Optional[float] = None, ax: Ax = None, **path_kwargs, ) -> Ax: """Plot path integral at single (x,y,z) coordinate. Parameters ---------- x : float = None Position of plane in x direction, only one of x,y,z can be specified to define plane. y : float = None Position of plane in y direction, only one of x,y,z can be specified to define plane. z : float = None Position of plane in z direction, only one of x,y,z can be specified to define plane. ax : matplotlib.axes._subplots.Axes = None Matplotlib axes to plot on, if not specified, one is created. **path_kwargs Optional keyword arguments passed to the matplotlib plotting of the line. For details on accepted values, refer to `Matplotlib's documentation <https://tinyurl.com/36marrat>`_. Returns ------- matplotlib.axes._subplots.Axes The supplied or created matplotlib axes. """ axis, position = self.parse_xyz_kwargs(x=x, y=y, z=z) if axis != self.main_axis or not np.isclose(position, self.center[axis], rtol=fp_eps): return ax plot_params = plot_params_current_path.include_kwargs(**path_kwargs) plot_kwargs = plot_params.to_kwargs() path_integrals = self._to_path_integrals() # Plot the path for path in path_integrals: (xs, ys) = path._vertices_2D(axis) ax.plot(xs, ys, **plot_kwargs) (ax1, ax2) = self.remaining_axes # Add arrow to bottom path, unless right path is longer arrow_path = path_integrals[0] if self.size[ax2] > self.size[ax1]: arrow_path = path_integrals[1] (xs, ys) = arrow_path._vertices_2D(axis) X = (xs[0] + xs[1]) / 2 Y = (ys[0] + ys[1]) / 2 center = np.array([X, Y]) dx = xs[1] - xs[0] dy = ys[1] - ys[0] direction = np.array([dx, dy]) segment_length = np.linalg.norm(direction) unit_dir = direction / segment_length # Change direction of arrow depending on sign of current definition if self.sign == "-": unit_dir *= -1.0 # Change direction of arrow when the "y" axis is dropped, # since the plotted coordinate system will be left-handed (x, z) if self.main_axis == 1: unit_dir *= -1.0 start = center - unit_dir * segment_length end = center ax.annotate( "", xytext=(start[0], start[1]), xy=(end[0], end[1]), arrowprops=ARROW_CURRENT, ) return ax