Source code for tidy3d.plugins.microwave.custom_path_integrals

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

from __future__ import annotations

from typing import Literal

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

from ...components.base import cached_property
from ...components.data.data_array import FreqDataArray, FreqModeDataArray, TimeDataArray
from ...components.data.monitor_data import FieldData, FieldTimeData, ModeSolverData
from ...components.types import ArrayFloat2D, Axis
from ...constants import MICROMETER, fp_eps
from ...exceptions import DataError, SetupError
from .path_integrals import AbstractAxesRH, IntegralResultTypes, MonitorDataTypes

FieldParameter = Literal["E", "H"]


[docs] class CustomPathIntegral2D(AbstractAxesRH): """Class for defining a custom path integral defined as a curve on an axis-aligned plane. Notes ----- Given a set of vertices :math:`\\vec{r}_i`, this class approximates path integrals over vector fields of the form :math:`\\int{\\vec{F} \\cdot \\vec{dl}}` as :math:`\\sum_i{\\vec{F}(\\vec{r}_i) \\cdot \\vec{dl}_i}`, where the differential length :math:`\\vec{dl}` is approximated using central differences :math:`\\vec{dl}_i = \\frac{\\vec{r}_{i+1} - \\vec{r}_{i-1}}{2}`. If the path is not closed, forward and backward differences are used at the endpoints. """ axis: Axis = pd.Field( 2, title="Axis", description="Specifies dimension of the planar axis (0,1,2) -> (x,y,z)." ) position: float = pd.Field( ..., title="Position", description="Position of the plane along the ``axis``.", ) vertices: ArrayFloat2D = pd.Field( ..., title="Vertices", description="List of (d1, d2) defining the 2 dimensional positions of the path. " "The index of dimension should be in the ascending order, which means " "if the axis corresponds with ``y``, the coordinates of the vertices should be (x, z). " "If you wish to indicate a closed contour, the final vertex should be made " "equal to the first vertex, i.e., ``vertices[-1] == vertices[0]``", units=MICROMETER, )
[docs] def compute_integral( self, field: FieldParameter, em_field: MonitorDataTypes ) -> IntegralResultTypes: """Computes the path integral defined by ``vertices`` given the input ``em_field``. Parameters ---------- field : :class:`.FieldParameter` Can take the value of ``"E"`` or ``"H"``. Determines whether to perform the integral over electric or magnetic field. em_field : :class:`.MonitorDataTypes` The electromagnetic field data that will be used for integrating. Returns ------- :class:`.IntegralResultTypes` Result of integral over remaining dimensions (frequency, time, mode indices). """ if not isinstance(em_field, (FieldData, FieldTimeData, ModeSolverData)): raise DataError("'em_field' type not supported.") (dim1, dim2, dim3) = self.local_dims h_field_name = f"{field}{dim1}" v_field_name = f"{field}{dim2}" # Validate that fields are present if h_field_name not in em_field.field_components: raise DataError(f"'field_name' '{h_field_name}' not found.") if v_field_name not in em_field.field_components: raise DataError(f"'field_name' '{v_field_name}' not found.") # Select fields lying on the plane plane_indexer = {dim3: self.position} field1 = em_field.field_components[h_field_name].sel(plane_indexer, method="nearest") field2 = em_field.field_components[v_field_name].sel(plane_indexer, method="nearest") # Although for users we use the convention that an axis is simply `popped` # internally we prefer a right-handed coordinate system where dimensions # keep a proper order. The only change is to swap 'x' and 'z' when the # normal axis is along `y` # Dim 's' represents the parameterization of the line # 't' is likely used for time if self.main_axis == 1: x_path = xr.DataArray(self.vertices[:, 1], dims="s") y_path = xr.DataArray(self.vertices[:, 0], dims="s") else: x_path = xr.DataArray(self.vertices[:, 0], dims="s") y_path = xr.DataArray(self.vertices[:, 1], dims="s") path_indexer = {dim1: x_path, dim2: y_path} field1_interp = field1.interp(path_indexer, method="linear") field2_interp = field2.interp(path_indexer, method="linear") # Determine the differential length elements along the path dl_x = self._compute_dl_component(x_path, self.is_closed_contour) dl_y = self._compute_dl_component(y_path, self.is_closed_contour) dl_x = xr.DataArray(dl_x, dims="s") dl_y = xr.DataArray(dl_y, dims="s") # Compute the dot product between differential length element and vector field integrand = field1_interp * dl_x + field2_interp * dl_y # Integrate along the path result = integrand.integrate(coord="s") result = result.reset_coords(drop=True) if isinstance(em_field, FieldData): return FreqDataArray(data=result.data, coords=result.coords) elif isinstance(em_field, FieldTimeData): return TimeDataArray(data=result.data, coords=result.coords) else: assert isinstance(em_field, ModeSolverData) return FreqModeDataArray(data=result.data, coords=result.coords)
@staticmethod def _compute_dl_component(coord_array: xr.DataArray, closed_contour=False) -> np.array: """Computes the differential length element along the integration path.""" dl = np.gradient(coord_array) if closed_contour: # If the contour is closed, we can use central difference on the starting/end point # which will be more accurate than the default forward/backward choice in np.gradient grad_end = np.gradient([coord_array[-2], coord_array[0], coord_array[1]]) dl[0] = dl[-1] = grad_end[1] return dl @cached_property def is_closed_contour(self) -> bool: return np.isclose( self.vertices[0, :], self.vertices[-1, :], rtol=fp_eps, atol=np.finfo(np.float32).smallest_normal, ).all() @cached_property def main_axis(self) -> Axis: """Axis for performing integration.""" return self.axis @pd.validator("vertices", always=True) def _correct_shape(cls, val): """Makes sure vertices size is correct.""" # overall shape of vertices if val.shape[1] != 2: raise SetupError( "'CustomPathIntegral2D.vertices' must be a 2 dimensional array shaped (N, 2). " f"Given array with shape of '{val.shape}'." ) return val
[docs] class CustomVoltageIntegral2D(CustomPathIntegral2D): """Class for computing the voltage between two points defined by a custom path. Computed voltage is :math:`V=V_b-V_a`, where position b is the final vertex in the supplied path. Notes ----- Use :class:`.VoltageIntegralAxisAligned` if possible, since interpolation near conductors will not be accurate. .. TODO Improve by including extrapolate_to_endpoints field, non-trivial extension."""
[docs] def compute_voltage(self, em_field: MonitorDataTypes) -> IntegralResultTypes: """Compute voltage along path defined by a line. Parameters ---------- em_field : :class:`.MonitorDataTypes` The electromagnetic field data that will be used for integrating. Returns ------- :class:`.IntegralResultTypes` Result of voltage computation over remaining dimensions (frequency, time, mode indices). """ voltage = -1.0 * self.compute_integral(field="E", em_field=em_field) return voltage
[docs] class CustomCurrentIntegral2D(CustomPathIntegral2D): """Class for computing conduction current via Ampère's circuital law on a custom path. To compute the current flowing in the positive ``axis`` direction, the vertices should be ordered in a counterclockwise direction."""
[docs] def compute_current(self, em_field: MonitorDataTypes) -> IntegralResultTypes: """Compute current flowing in a custom loop. Parameters ---------- em_field : :class:`.MonitorDataTypes` The electromagnetic field data that will be used for integrating. Returns ------- :class:`.IntegralResultTypes` Result of current computation over remaining dimensions (frequency, time, mode indices). """ current = self.compute_integral(field="H", em_field=em_field) return current