Source code for tidy3d.components.beam

"""Classes for creating data based on analytic beams like plane wave, Gaussian beam, and
astigmatic Gaussian beam."""

from __future__ import annotations

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

import autograd.numpy as np
import pydantic.v1 as pd

from tidy3d.constants import C_0, ETA_0, HERTZ, MICROMETER, RADIAN

from .base import cached_property
from .data.data_array import ScalarFieldDataArray
from .data.monitor_data import FieldData
from .geometry.base import Box
from .grid.grid import Coords, Grid
from .medium import Medium, MediumType
from .monitor import FieldMonitor
from .source.field import FixedAngleSpec, FixedInPlaneKSpec
from .types import TYPE_TAG_STR, Direction, FreqArray, Numpy
from .validators import assert_plane

DEFAULT_RESOLUTION = 200


class BeamProfile(Box):
    """Base class for handling analytic beams."""

    resolution: float = pd.Field(
        DEFAULT_RESOLUTION,
        title="Sampling resolution",
        description="Sampling resolution in the tangential directions of the beam (defines a "
        "number of equally spaced points).",
        units=MICROMETER,
    )

    freqs: FreqArray = pd.Field(
        ...,
        title="Frequencies",
        description="List of frequencies at which the beam is sampled.",
        units=HERTZ,
    )

    background_medium: MediumType = pd.Field(
        Medium(),
        title="Background Medium",
        description="Background medium in which the beam is embedded.",
    )

    angle_theta: float = pd.Field(
        0.0,
        title="Polar Angle",
        description="Polar angle of the propagation axis from the normal axis.",
        units=RADIAN,
    )

    angle_phi: float = pd.Field(
        0.0,
        title="Azimuth Angle",
        description="Azimuth angle of the propagation axis in the plane orthogonal to the "
        "normal axis.",
        units=RADIAN,
    )

    pol_angle: float = pd.Field(
        0.0,
        title="Polarization Angle",
        description="Specifies the angle between the electric field polarization of the "
        "beam and the plane defined by the normal axis and the propagation axis (rad). "
        "``pol_angle=0`` (default) specifies P polarization, "
        "while ``pol_angle=np.pi/2`` specifies S polarization. "
        "At normal incidence when S and P are undefined, ``pol_angle=0`` defines: "
        "- ``Ey`` polarization for propagation along ``x``."
        "- ``Ex`` polarization for propagation along ``y``."
        "- ``Ex`` polarization for propagation along ``z``.",
        units=RADIAN,
    )

    direction: Direction = pd.Field(
        "+",
        title="Direction",
        description="Specifies propagation in the positive or negative direction of the normal "
        "axis.",
    )

    _plane_validator = assert_plane()

    @property
    def grid(self) -> Grid:
        """Return a Grid object on which the beam will be sampled."""
        dim_n, dim_tan = self.pop_axis("xyz", self.size.index(0.0))
        bounds_n, bounds_tan = self.pop_axis(np.array(self.bounds).T, self.size.index(0.0))
        boundaries = {
            dim_n: bounds_n,
            dim_tan[0]: np.linspace(bounds_tan[0][0], bounds_tan[0][1], int(self.resolution)),
            dim_tan[1]: np.linspace(bounds_tan[1][0], bounds_tan[1][1], int(self.resolution)),
        }
        return Grid(boundaries=boundaries)

    @property
    def monitor(self) -> FieldMonitor:
        """``FieldMonitor`` with the same center, size, and frequencies as the beam, to be used
        in the ``FieldData`` created by the ``field_data`` method."""
        return FieldMonitor(
            size=self.size,
            center=self.center,
            freqs=self.freqs,
            name="<<BEAM_DATA>>",
        )

    @cached_property
    def field_data(self) -> FieldData:
        """Compute a FieldData for the spatial E and H field amplitudes of an analytically defined
        beam."""

        background_n = np.zeros(len(self.freqs), dtype=complex)
        for freq_id, freq in enumerate(self.freqs):
            eps = self.background_medium.eps_model(freq)
            nk_medium = self.background_medium.eps_complex_to_nk(eps)
            background_n[freq_id] = np.squeeze(nk_medium[0]) + 1j * np.squeeze(nk_medium[1])

        data_dict = self._field_data_on_grid(grid=self.grid, background_n=background_n)
        data_raw = FieldData(monitor=self.monitor, grid_expanded=self.grid, **data_dict)

        # Normalize by the flux
        fields_norm = {}
        flux = np.abs(data_raw.flux)
        for field_name, field_data in data_raw.field_components.items():
            fields_norm[field_name] = (field_data / np.sqrt(flux)).astype(field_data.dtype)

        return data_raw.updated_copy(**fields_norm)

    def _field_data_on_grid(self, grid: Grid, background_n: Numpy, colocate=True) -> dict:
        """Compute the field data for each field component on a grid for the beam.
        A dictionary of the scalar field data arrays is returned, not yet packaged as ``FieldData``.
        """

        field_components = ["Ex", "Ey", "Ez", "Hx", "Hy", "Hz"]
        if colocate:
            # Just sample at the grid boundaries for each field component
            # We skip the last boundary because that's how things also work in the solver (no
            # field defined at the last boundary)
            coords_dict = {key: val[:-1] for key, val in grid.boundaries.to_dict.items()}
            grid_dict = {component: Coords(**coords_dict) for component in field_components}
        else:
            # Yee grid for each field component
            grid_dict = grid.yee.grid_dict

        # Compute each field component over the beam grid
        scalar_fields = {}
        for comp, field in enumerate(field_components):
            x, y, z = grid_dict[field].to_list
            # Center component grid at beam center
            x_c, y_c, z_c = (coords - cent for coords, cent in zip((x, y, z), self.center))
            # Stack E and H fields
            field_vals = self.analytic_beam(x_c, y_c, z_c, background_n, field=field[0])
            # Get the current field component
            field_vals = field_vals[comp % 3]
            # Make the ScalarFieldDataArray for the current component
            coords = {"x": x, "y": y, "z": z, "f": np.array(self.freqs)}
            field_data = ScalarFieldDataArray(field_vals, coords=coords)
            scalar_fields[field] = field_data

        return scalar_fields

    @abstractmethod
    def scalar_field(self, points: Numpy, background_n: float) -> Numpy:
        """Scalar field corresponding to the analytic beam in coordinate system such that the
        propagation direction is z and the ``E``-field is entirely ``x``-polarized. The field is
        computed on an unstructured array ``points`` of shape ``(3, ...)``."""

    def analytic_beam_z_normal(
        self, points: Numpy, background_n: float, field: Literal["E", "H"]
    ) -> Numpy:
        """Analytic beam with all the beam parameters but assuming ``z`` as the normal axis."""

        # Add a frequency dimension to points
        points = np.repeat(points[:, :, np.newaxis], len(self.freqs), axis=2)

        # Rotate points to axes where propagation is along z
        points_prop_z = self._rotate_points_z(points, background_n)

        # Reflection at the z = 0 plane for negative direction
        if self.direction == "-":
            points_prop_z[2, ...] *= -1

        # Get fields polarized along x and propagating along z
        scalar_field = self.scalar_field(points_prop_z, background_n)

        # Set the correct field component based on the scalar field values
        field_vals = np.zeros(points.shape, dtype=np.complex128)
        if field == "E":
            field_vals[0, ...] = scalar_field
        else:
            field_vals[1, ...] = scalar_field / ETA_0 * background_n

        # Rotate polarization
        field_vals = self.rotate_points(field_vals, [0, 0, 1], self.pol_angle)

        # Reflection of the field components at the z = 0 plane for negative direction
        if self.direction == "-":
            if field == "E":
                field_vals[2, :] *= -1
            else:
                field_vals[:2, :] *= -1

        # Rotate the fields back to the original propagation axes
        field_vals = self._inverse_rotate_field_vals_z(field_vals, background_n)

        return field_vals

    def analytic_beam(
        self,
        x: Numpy,
        y: Numpy,
        z: Numpy,
        background_n: float,
        field: Literal["E", "H"],
    ) -> Numpy:
        """Sample the analytic beam fields on a cartesian grid of points in x, y, z."""

        # Make a meshgrid
        (x_mesh, y_mesh, z_mesh) = np.meshgrid(x, y, z, indexing="ij")
        (Nx, Ny, Nz) = x_mesh.shape

        # Move to coordinates where the normal axis is z
        (z_new, (x_new, y_new)) = self.pop_axis((x_mesh, y_mesh, z_mesh), axis=self.size.index(0.0))
        points = np.stack([coords.ravel() for coords in (x_new, y_new, z_new)], axis=0)

        # Sample the plane wave fields
        f_x, f_y, f_z = self.analytic_beam_z_normal(points, background_n, field)

        # Move back to original coordinates
        field_vals = np.stack(self.unpop_axis(f_z, (f_x, f_y), axis=self.size.index(0.0)), axis=0)

        # H field gets a -1 factor if a reflection was involved
        if self.size.index(0.0) == 1 and field == "H":
            field_vals *= -1

        # Reshape to (3, Nx, Ny, Nz, num_freqs)
        return np.reshape(field_vals, (3, Nx, Ny, Nz, len(self.freqs)))

    def _rotate_points_z(self, points: Numpy, background_n: Numpy) -> Numpy:
        """Rotate points to new coordinates where z is the propagation axis."""
        points_prop_z = self.rotate_points(points, [0, 0, 1], -self.angle_phi)
        points_prop_z = self.rotate_points(points_prop_z, [0, 1, 0], -self.angle_theta)
        return points_prop_z

    def _inverse_rotate_field_vals_z(self, field_vals: Numpy, background_n: Numpy) -> Numpy:
        """Rotate field values from coordinates where z is the propagation axis to angled
        coordinates."""
        field_vals = self.rotate_points(field_vals, [0, 1, 0], self.angle_theta)
        field_vals = self.rotate_points(field_vals, [0, 0, 1], self.angle_phi)
        return field_vals


[docs] class PlaneWaveBeamProfile(BeamProfile): """Component for constructing plane wave beam data. The normal direction is implicitly defined by the ``size`` parameter. See also :class:`.PlaneWave`. """ angular_spec: Union[FixedInPlaneKSpec, FixedAngleSpec] = pd.Field( FixedAngleSpec(), title="Angular Dependence Specification", description="Specification of plane wave propagation direction dependence on wavelength.", discriminator=TYPE_TAG_STR, ) as_fixed_angle_source: bool = pd.Field( False, title="Fixed Angle Flag", description="Fixed angle flag. Only used internally when computing source beams for " "injection in an FDTD simulation with fixed angle boudnaries. Use ``angular_spec`` to " "switch between waves with fixed angle and fixed in-plane k.", ) angle_theta_frequency: Optional[float] = pd.Field( None, title="Frequency at Which Angle Theta is Defined", description="Frequency for which ``angle_theta`` is set. This only has an effect for " "fixed in-plane wave-vector beams. If not supplied, the average of the beam ``freqs`` is " "used.", ) @property def _angle_theta_frequency(self): if not self.angle_theta_frequency: return np.mean(self.freqs) return self.angle_theta_frequency
[docs] def in_plane_k(self, background_n: float): """In-plane wave vector. Only the real part is taken so the beam has no in-plane decay.""" k0 = 2 * np.pi * self._angle_theta_frequency / C_0 * background_n k_in_plane = k0.real * np.sin(self.angle_theta) return [k_in_plane * np.cos(self.angle_phi), k_in_plane * np.sin(self.angle_phi)]
[docs] def scalar_field(self, points: Numpy, background_n: float) -> Numpy: """Scalar field for plane wave. Scalar field corresponding to the analytic beam in coordinate system such that the propagation direction is z and the ``E``-field is entirely ``x``-polarized. The field is computed on an unstructured array ``points`` of shape ``(3, N_points, N_freqs)``. For the special case of fixed in-plane k, the propagation axis is different at every frequency, and the points a frquency-dependent rotation has been applied to the ``points`` in ``self._rotate_points_z``. """ kz = 2 * np.pi * np.array(self.freqs) / C_0 * background_n if self.as_fixed_angle_source: kz *= np.cos(self.angle_theta) return np.exp(1j * points[2] * kz)
def _angle_theta_actual(self, background_n: Numpy) -> Numpy: """Compute the frequency-dependent actual propagation angle theta.""" k0 = 2 * np.pi * np.array(self.freqs) / C_0 * background_n kx, ky = self.in_plane_k(background_n) k_perp = np.sqrt(kx**2 + ky**2) return np.real(np.arcsin(k_perp / k0)) * np.sign(self.angle_theta) def _rotate_points_z(self, points: Numpy, background_n: Numpy) -> Numpy: """Rotate points to new coordinates where z is the propagation axis.""" if self.as_fixed_angle_source: # For fixed-angle, we do not rotate the points return points if isinstance(self.angular_spec, FixedInPlaneKSpec): # For fixed in-plane k, the rotation is angle-dependent points = self.rotate_points(points, [0, 0, 1], -self.angle_phi) angle_theta_actual = self._angle_theta_actual(background_n=background_n) for ind, theta_actual in enumerate(angle_theta_actual): points[:, :, ind] = self.rotate_points(points[:, :, ind], [0, 1, 0], -theta_actual) return points return super()._rotate_points_z(points, background_n) def _inverse_rotate_field_vals_z(self, field_vals: Numpy, background_n: Numpy) -> Numpy: """Rotate field values from coordinates where z is the propagation axis to angled coordinates. Special handling is needed if fixed in-plane k wave.""" if isinstance(self.angular_spec, FixedInPlaneKSpec): # For fixed in-plane k, the rotation is angle-dependent angle_theta_actual = self._angle_theta_actual(background_n=background_n) for ind, theta_actual in enumerate(angle_theta_actual): field_vals[:, :, ind] = self.rotate_points( field_vals[:, :, ind], [0, 1, 0], theta_actual ) field_vals = self.rotate_points(field_vals, [0, 0, 1], self.angle_phi) return field_vals return super()._inverse_rotate_field_vals_z(field_vals, background_n)
[docs] class GaussianBeamProfile(BeamProfile): """Component for constructing Gaussian beam data. The normal direction is implicitly defined by the ``size`` parameter. See also :class:`.GaussianBeam`. """ waist_radius: pd.PositiveFloat = pd.Field( 1.0, title="Waist Radius", description="Radius of the beam at the waist.", units=MICROMETER, ) waist_distance: float = pd.Field( 0.0, title="Waist Distance", description="Distance from the beam waist along the propagation direction. " "A positive value means the waist is positioned behind the beam, considering the propagation direction. " "For example, for a beam propagating in the ``+`` direction, a positive value of ``beam_distance`` " "means the beam waist is positioned in the ``-`` direction (behind the beam). " "A negative value means the beam waist is in the ``+`` direction (in front of the beam). " "For an angled beam, the distance is defined along the rotated propagation direction.", units=MICROMETER, )
[docs] def beam_params(self, z: Numpy, k0: Numpy) -> tuple[Numpy, Numpy, Numpy]: """Compute the parameters needed to evaluate a Gaussian beam at z. Parameters ---------- z : Numpy Axial distance from the beam center. k0 : Numpy Wave vector magnitude. """ w_0, z_0 = self.waist_radius, self.waist_distance z_r = w_0**2 * k0 / 2 # shape k0 w_z = w_0 * np.sqrt(1 + ((z + z_0) / z_r) ** 2) # shape (Np, Nk0) # inv_r_z shape (Np, Nk0) inv_r_z = (z + z_0) / ((z + z_0) ** 2 + z_r**2) # we choose gauge such that psi_g = 0 at z = 0 (beam plane) # this is needed for a proper interpolation between different frequencies # psi_g shape (Np, Nk0) psi_g = np.arctan((z + z_0) / z_r) - np.arctan(z_0 / z_r) return w_z, inv_r_z, psi_g
[docs] def scalar_field(self, points: Numpy, background_n: float) -> Numpy: """Scalar field for Gaussian beam. Scalar field corresponding to the analytic beam in coordinate system such that the propagation direction is z and the ``E``-field is entirely ``x``-polarized. The field is computed on an unstructured array ``points`` of shape ``(3, ...)``. """ k0 = 2 * np.pi * np.array(self.freqs) / C_0 * background_n x, y, z = points w_0 = self.waist_radius w_z, inv_r_z, psi_g = self.beam_params(z, k0) r_2 = x**2 + y**2 scalar_gaussian = w_0 / w_z scalar_gaussian *= np.exp(-r_2 / w_z**2) scalar_gaussian *= np.exp(1j * (z * k0 + r_2 * k0 / 2 * inv_r_z - psi_g)) return scalar_gaussian
[docs] class AstigmaticGaussianBeamProfile(BeamProfile): """Component for constructing astigmatic Gaussian beam data. The normal direction is implicitly defined by the ``size`` parameter. See also :class:`.AstigmaticGaussianBeam`. """ waist_sizes: tuple[pd.PositiveFloat, pd.PositiveFloat] = pd.Field( (1.0, 1.0), title="Waist sizes", description="Size of the beam at the waist in the local x and y directions.", units=MICROMETER, ) waist_distances: tuple[float, float] = pd.Field( (0.0, 0.0), title="Waist distances", description="Distance to the beam waist along the propagation direction " "for the waist sizes in the local x and y directions. " "When ``direction`` is ``+`` and ``waist_distances`` are positive, the waist " "is on the ``-`` side (behind) the beam plane. When ``direction`` is ``+`` and " "``waist_distances`` are negative, the waist is on the ``+`` side (in front) of " "the beam plane.", units=MICROMETER, )
[docs] def beam_params(self, z: Numpy, k0: Numpy) -> tuple[Numpy, Numpy, Numpy, Numpy]: """Compute the parameters needed to evaluate an astigmatic Gaussian beam at z. Parameters ---------- z : Numpy Axial distance from the beam center. k0 : Numpy Wave vector magnitude. """ w_xy, z_xy = self.waist_sizes, self.waist_distances # shape (2, ) z_r = [w**2 * k0 / 2 for w in w_xy] # shape (2, Nk0) w_z, w_0, inv_r_z, psi_g = [], [], [], [] # final shape (2, Np, Nk0) after loop below for w, z_i, z_ri in zip(w_xy, z_xy, z_r): w_z.append(w * np.sqrt(1 + ((z + z_i) / z_ri) ** 2)) w_0.append(w * np.sqrt(1 + (z_i / z_ri) ** 2)) inv_r_z.append((z + z_i) / ((z + z_i) ** 2 + z_ri**2)) # we choose gauge such that psi_g = 0 at z = 0 (beam plane) # this is needed for a proper interpolation between different frequencies # psi_g shape (Np, Nk0) psi_g_1 = np.arctan((z + z_i) / z_ri) / 2 psi_g_2 = np.arctan(z_i / z_ri) / 2 psi_g.append(psi_g_1 - psi_g_2) return w_0, w_z, inv_r_z, psi_g
[docs] def scalar_field(self, points: Numpy, background_n: float) -> Numpy: """ Scalar field for astigmatic Gaussian beam. Scalar field corresponding to the analytic beam in coordinate system such that the propagation direction is z and the ``E``-field is entirely ``x``-polarized. The field is computed on an unstructured array ``points`` of shape ``(3, ...)``. """ k0 = 2 * np.pi * np.array(self.freqs) / C_0 * background_n x, y, z = points w_0, w_z, inv_r_z, psi_g = self.beam_params(z, k0) x_2 = x**2 y_2 = y**2 q_term1_inv = 1j * x_2 * k0 / 2 * inv_r_z[0] - x_2 / w_z[0] ** 2 q_term2_inv = 1j * y_2 * k0 / 2 * inv_r_z[1] - y_2 / w_z[1] ** 2 angle_term = q_term1_inv + q_term2_inv + 1j * (z * k0 - psi_g[0] - psi_g[1]) scalar_gaussian = np.exp(angle_term) ampl = np.sqrt(w_0[0] * w_0[1] / w_z[0] / w_z[1]) return scalar_gaussian * ampl