Source code for PyMieSim.single.representations.farfields

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from ._plotting import AngularPlotMixin, resolve_colormap, signed_normalization, intensity_normalization

from typing import Sequence

import matplotlib.pyplot as pyplot
import numpy
from matplotlib import cm
from matplotlib import colors

from MPSPlots.colormaps import blue_black_red

from PyMieSim.units import ureg, Length


[docs] class FarFields(AngularPlotMixin): r""" Far-field scattering representation. This class computes and visualizes the complex far-field electric-field components returned by a single-scatterer setup. The far-field components are evaluated on a spherical angular mesh and are returned as the transverse components :math:`E_\phi` and :math:`E_\theta`. The default visualization is a single three-dimensional spherical plot. A single field is shown at a time to keep rendering responsive and to make the figure easier to interpret. """ def __init__( self, setup: object, sampling: int = 200, distance: Length = 1.0 * ureg.meter, ) -> None: """Compute far-field components on a structured angular mesh. Parameters ---------- setup : object Single-scatterer setup exposing ``get_farfields``. sampling : int, optional Number of angular samples along each mesh direction. distance : pint.Quantity, optional Observation distance at which the fields are evaluated. Attributes ---------- E_phi, E_theta : pint.Quantity Complex transverse far-field components. mesh : object Structured spherical mesh associated with the field arrays. """ self.setup = setup self.sampling = sampling self.distance = distance self.E_phi, self.E_theta, self.mesh = self.setup.get_farfields( sampling=self.sampling, distance=self.distance, )
[docs] def plot( self, field: str = "total_intensity", unit_size: Sequence[float] = (5.0, 5.0), background_color: str = "white", colormap=None, opacity: float = 1.0, show_edges: bool = False, show_axis_label: bool = False, show_colorbar: bool = True, show_vector_field: bool = False, vector_count: int = 10, vector_radius: float = 1.1, vector_length: float = 0.08, vector_color: str = "black", scale: str = "log", percentile_clip: float | None = None, surface_linewidth: float = 0.0, surface_antialiased: bool = False, elevation: float = 25.0, azimuth: float = 35.0, ): r""" Plot one far-field quantity on a three-dimensional spherical surface. Parameters ---------- field : str, optional Quantity to plot. Supported values are: ``"total_intensity"`` Total intensity, computed as ``|E_phi|^2 + |E_theta|^2``. ``"phi_intensity"`` Phi component intensity, computed as ``|E_phi|^2``. ``"theta_intensity"`` Theta component intensity, computed as ``|E_theta|^2``. ``"phi_real"`` Real part of :math:`E_\phi`. ``"phi_imag"`` Imaginary part of :math:`E_\phi`. ``"theta_real"`` Real part of :math:`E_\theta`. ``"theta_imag"`` Imaginary part of :math:`E_\theta`. ``"phi_phase"`` Phase of :math:`E_\phi`. ``"theta_phase"`` Phase of :math:`E_\theta`. ``"phase_difference"`` Wrapped phase difference ``arg(E_theta) - arg(E_phi)``. unit_size : Sequence[float], optional Figure size in inches, given as ``(width, height)``. background_color : str, optional Figure and axis background color. colormap : str or matplotlib.colors.Colormap, optional Colormap used for the selected field. If ``None``, ``MPSPlots.colormaps.blue_black_red`` is used. opacity : float, optional Surface opacity. show_edges : bool, optional If ``True``, draw surface mesh edges. show_axis_label : bool, optional If ``True``, display Cartesian axis labels, ticks, and panes. show_colorbar : bool, optional If ``True``, add a colorbar. show_vector_field : bool, optional If ``True``, overlay sparse local basis vectors. Phi fields receive phi vectors, theta fields receive theta vectors, and total fields do not receive a vector overlay. vector_count : int, optional Number of angular samples per direction used for vector overlays. vector_radius : float, optional Radius at which direction vectors are drawn. vector_length : float, optional Display length of direction vectors. vector_color : str, optional Color of the direction vectors. scale : str, optional Scaling for intensity fields. Supported values are ``"linear"`` and ``"log"``. Ignored for signed and phase fields. percentile_clip : float, optional If provided, upper color limits are clipped to this percentile. surface_linewidth : float, optional Line width used for surface edges. surface_antialiased : bool, optional Matplotlib antialiasing flag for surfaces. elevation : float, optional Matplotlib 3D view elevation angle in degrees. azimuth : float, optional Matplotlib 3D view azimuth angle in degrees. Returns ------- matplotlib.figure.Figure Matplotlib figure containing the spherical far-field plot. """ field = field.lower().strip() cartesian = self.mesh.spherical_mesh.to_cartesian() x_coordinates = self._as_square_array( self._quantity_to_magnitude_array(cartesian.x, "meter") ) y_coordinates = self._as_square_array( self._quantity_to_magnitude_array(cartesian.y, "meter") ) z_coordinates = self._as_square_array( self._quantity_to_magnitude_array(cartesian.z, "meter") ) field_values, field_label, field_type, field_basis = self._get_field_data(field) figure = pyplot.figure( figsize=(unit_size[0], unit_size[1]), facecolor=background_color, ) ax = figure.add_subplot(1, 1, 1, projection="3d") self._plot_field_surface( ax=ax, x_coordinates=x_coordinates, y_coordinates=y_coordinates, z_coordinates=z_coordinates, field=field_values, label=field_label, field_type=field_type, colormap=colormap, opacity=opacity, show_edges=show_edges, show_colorbar=show_colorbar, scale=scale, percentile_clip=percentile_clip, surface_linewidth=surface_linewidth, surface_antialiased=surface_antialiased, ) if show_vector_field: if field_basis == "phi": self.add_phi_vector_to_3d_plot( ax=ax, n_points=vector_count, radius=vector_radius, length=vector_length, color=vector_color, opacity=1.0, ) if field_basis == "theta": self.add_theta_vector_to_3d_plot( ax=ax, n_points=vector_count, radius=vector_radius, length=vector_length, color=vector_color, opacity=1.0, ) self._format_3d_axis( ax=ax, background_color=background_color, show_axis_label=show_axis_label, elevation=elevation, azimuth=azimuth, ) figure.tight_layout() pyplot.show() return figure
[docs] def plot_heatmap( self, kind: str = "intensity", unit_size: Sequence[float] = (4.2, 3.6), colormap=None, background_color: str = "white", show_colorbar: bool = True, scale: str = "log", percentile_clip: float | None = None, interpolation: str = "nearest", ): r""" Plot far-field quantities as two-dimensional angular heatmaps. Parameters ---------- kind : str, optional Plot type. Supported values are ``"intensity"``, ``"complex"``, and ``"phase"``. unit_size : Sequence[float], optional Size of each panel in inches, given as ``(width, height)``. colormap : str or matplotlib.colors.Colormap, optional Colormap used for the plotted values. If ``None``, ``MPSPlots.colormaps.blue_black_red`` is used. background_color : str, optional Figure background color. show_colorbar : bool, optional If ``True``, add one colorbar to each panel. scale : str, optional Scaling for intensity plots. Supported values are ``"linear"`` and ``"log"``. Ignored for ``"complex"`` and ``"phase"``. percentile_clip : float, optional If provided, upper color limits are clipped to this percentile of the finite plotted values. interpolation : str, optional Interpolation mode passed to ``imshow``. Returns ------- matplotlib.figure.Figure Matplotlib figure containing the far-field heatmaps. """ kind = kind.lower().strip() if kind == "intensity": return self._plot_intensity_heatmaps( unit_size=unit_size, colormap=colormap, background_color=background_color, show_colorbar=show_colorbar, scale=scale, percentile_clip=percentile_clip, interpolation=interpolation, ) if kind == "complex": return self._plot_complex_heatmaps( unit_size=unit_size, colormap=colormap, background_color=background_color, show_colorbar=show_colorbar, percentile_clip=percentile_clip, interpolation=interpolation, ) if kind == "phase": return self._plot_phase_heatmaps( unit_size=unit_size, colormap=colormap, background_color=background_color, show_colorbar=show_colorbar, interpolation=interpolation, ) raise ValueError( "Unknown far-field heatmap kind. Expected one of: " "'intensity', 'complex', or 'phase'." )
[docs] def add_theta_vector_to_3d_plot( self, ax, n_points: int = 10, opacity: float = 1.0, radius: float = 1.0, length: float = 0.08, color: str = "black", ) -> None: r""" Add sparse unit vectors in the local :math:` \theta ` direction. This reproduces the previous PyVista call ``transform_vectors_sph_to_cart(theta=theta, phi=phi, r=radius, u=1, v=0, w=0)`` where ``theta`` is the azimuthal angle in degrees and ``phi`` is the polar angle in degrees. """ theta_mesh, phi_mesh = self._make_pyvista_style_angle_mesh(n_points) x_coordinates, y_coordinates, z_coordinates = self._spherical_to_cartesian( theta=theta_mesh, phi=phi_mesh, radius=radius, ) theta_x = numpy.cos(phi_mesh) * numpy.cos(theta_mesh) theta_y = numpy.cos(phi_mesh) * numpy.sin(theta_mesh) theta_z = -numpy.sin(phi_mesh) ax.quiver( x_coordinates, y_coordinates, z_coordinates, theta_x, theta_y, theta_z, length=length, normalize=True, color=color, alpha=opacity, linewidth=0.8, arrow_length_ratio=0.4, )
[docs] def add_phi_vector_to_3d_plot( self, ax, n_points: int = 10, opacity: float = 1.0, radius: float = 1.0, length: float = 0.08, color: str = "black", ) -> None: r""" Add sparse unit vectors in the local :math:` \phi ` direction. This reproduces the previous PyVista call ``transform_vectors_sph_to_cart(theta=theta, phi=phi, r=radius, u=0, v=1, w=0)`` where ``theta`` is the azimuthal angle in degrees and ``phi`` is the polar angle in degrees. """ theta_mesh, phi_mesh = self._make_pyvista_style_angle_mesh(n_points) x_coordinates, y_coordinates, z_coordinates = self._spherical_to_cartesian( theta=theta_mesh, phi=phi_mesh, radius=radius, ) phi_x = -numpy.sin(theta_mesh) phi_y = numpy.cos(theta_mesh) phi_z = numpy.zeros_like(theta_mesh) ax.quiver( x_coordinates, y_coordinates, z_coordinates, phi_x, phi_y, phi_z, length=length, normalize=True, color=color, alpha=opacity, linewidth=0.8, arrow_length_ratio=0.4, )
def _plot_field_surface( self, ax, x_coordinates: numpy.ndarray, y_coordinates: numpy.ndarray, z_coordinates: numpy.ndarray, field: numpy.ndarray, label: str, field_type: str, colormap, opacity: float, show_edges: bool, show_colorbar: bool, scale: str, percentile_clip: float | None, surface_linewidth: float, surface_antialiased: bool, ) -> None: """ Plot one scalar field on the far-field spherical mesh. """ field = numpy.asarray(field, dtype=float) colormap_object = self._resolve_colormap( colormap=colormap, field_type=field_type, ) normalization = self._get_normalization( field=field, field_type=field_type, scale=scale, percentile_clip=percentile_clip, ) facecolors = colormap_object(normalization(field)) facecolors[..., -1] = opacity edgecolor = "black" if show_edges else "none" linewidth = surface_linewidth if show_edges else 0.0 ax.plot_surface( x_coordinates, y_coordinates, z_coordinates, facecolors=facecolors, rstride=1, cstride=1, linewidth=linewidth, edgecolor=edgecolor, antialiased=surface_antialiased, shade=False, ) ax.set_title(label) if show_colorbar: scalar_mappable = cm.ScalarMappable( norm=normalization, cmap=colormap_object, ) scalar_mappable.set_array([]) ax.figure.colorbar( scalar_mappable, ax=ax, fraction=0.046, pad=0.04, label=label, ) def _plot_intensity_heatmaps( self, unit_size: Sequence[float], colormap, background_color: str, show_colorbar: bool, scale: str, percentile_clip: float | None, interpolation: str, ): """ Plot intensity-like quantities on angular heatmaps. """ e_phi = self._complex_field_array(self.E_phi) e_theta = self._complex_field_array(self.E_theta) phi_intensity = numpy.abs(e_phi) ** 2 theta_intensity = numpy.abs(e_theta) ** 2 total_intensity = phi_intensity + theta_intensity fields = [ phi_intensity, theta_intensity, total_intensity, ] labels = [ r"$|E_\phi|^2$", r"$|E_\theta|^2$", r"$|E_\phi|^2 + |E_\theta|^2$", ] return self._plot_heatmap_grid( fields=fields, labels=labels, field_type="intensity", unit_size=unit_size, colormap=colormap if colormap is not None else blue_black_red, background_color=background_color, show_colorbar=show_colorbar, scale=scale, percentile_clip=percentile_clip, interpolation=interpolation, ) def _plot_complex_heatmaps( self, unit_size: Sequence[float], colormap, background_color: str, show_colorbar: bool, percentile_clip: float | None, interpolation: str, ): """ Plot real and imaginary far-field components on angular heatmaps. """ e_phi = self._complex_field_array(self.E_phi) e_theta = self._complex_field_array(self.E_theta) fields = [ e_phi.real, e_phi.imag, e_theta.real, e_theta.imag, ] labels = [ r"$\operatorname{Re}(E_\phi)$", r"$\operatorname{Im}(E_\phi)$", r"$\operatorname{Re}(E_\theta)$", r"$\operatorname{Im}(E_\theta)$", ] return self._plot_heatmap_grid( fields=fields, labels=labels, field_type="signed", unit_size=unit_size, colormap=colormap if colormap is not None else blue_black_red, background_color=background_color, show_colorbar=show_colorbar, scale="linear", percentile_clip=percentile_clip, interpolation=interpolation, ) def _plot_phase_heatmaps( self, unit_size: Sequence[float], colormap, background_color: str, show_colorbar: bool, interpolation: str, ): """ Plot far-field phases on angular heatmaps. """ e_phi = self._complex_field_array(self.E_phi) e_theta = self._complex_field_array(self.E_theta) phi_phase = numpy.angle(e_phi) theta_phase = numpy.angle(e_theta) phase_difference = self._wrap_phase(theta_phase - phi_phase) fields = [ phi_phase, theta_phase, phase_difference, ] labels = [ r"$\arg(E_\phi)$", r"$\arg(E_\theta)$", r"$\arg(E_\theta) - \arg(E_\phi)$", ] return self._plot_heatmap_grid( fields=fields, labels=labels, field_type="phase", unit_size=unit_size, colormap=colormap if colormap is not None else blue_black_red, background_color=background_color, show_colorbar=show_colorbar, scale="linear", percentile_clip=None, interpolation=interpolation, ) def _plot_heatmap_grid( self, fields: list[numpy.ndarray], labels: list[str], field_type: str, unit_size: Sequence[float], colormap, background_color: str, show_colorbar: bool, scale: str, percentile_clip: float | None, interpolation: str, ): """ Plot a list of fields as angular heatmaps. """ figure = pyplot.figure( figsize=(unit_size[0] * len(fields), unit_size[1]), facecolor=background_color, ) axes = [ figure.add_subplot(1, len(fields), index + 1) for index in range(len(fields)) ] for ax, field, label in zip(axes, fields, labels): image = self._plot_single_heatmap( ax=ax, field=field, label=label, field_type=field_type, colormap=colormap, scale=scale, percentile_clip=percentile_clip, interpolation=interpolation, extent=(0.0, 360.0, 180.0, 0.0), ) ax.set_xlabel(r"$\theta$ [deg]") ax.set_ylabel(r"$\phi$ [deg]") ax.set_facecolor(background_color) if show_colorbar: figure.colorbar( image, ax=ax, fraction=0.046, pad=0.04, label=label, ) figure.tight_layout() pyplot.show() return figure def _plot_single_heatmap( self, ax, field: numpy.ndarray, label: str, field_type: str, colormap, scale: str, percentile_clip: float | None, interpolation: str, extent: tuple[float, float, float, float], ): """ Plot a single angular heatmap. """ field = numpy.asarray(field, dtype=float) colormap_object = self._resolve_colormap( colormap=colormap, field_type=field_type, ) normalization = self._get_normalization( field=field, field_type=field_type, scale=scale, percentile_clip=percentile_clip, ) image = ax.imshow( field, origin="upper", extent=extent, aspect="auto", interpolation=interpolation, cmap=colormap_object, norm=normalization, ) ax.set_title(label) return image def _get_field_data( self, field: str, ) -> tuple[numpy.ndarray, str, str, str | None]: """ Return data, label, field type, and basis family for one named field. """ e_phi = self._complex_field_array(self.E_phi) e_theta = self._complex_field_array(self.E_theta) phi_intensity = numpy.abs(e_phi) ** 2 theta_intensity = numpy.abs(e_theta) ** 2 total_intensity = phi_intensity + theta_intensity field_map = { "total_intensity": ( total_intensity, r"$|E_\phi|^2 + |E_\theta|^2$", "intensity", None, ), "phi_intensity": ( phi_intensity, r"$|E_\phi|^2$", "intensity", "phi", ), "theta_intensity": ( theta_intensity, r"$|E_\theta|^2$", "intensity", "theta", ), "phi_real": ( e_phi.real, r"$\operatorname{Re}(E_\phi)$", "signed", "phi", ), "phi_imag": ( e_phi.imag, r"$\operatorname{Im}(E_\phi)$", "signed", "phi", ), "theta_real": ( e_theta.real, r"$\operatorname{Re}(E_\theta)$", "signed", "theta", ), "theta_imag": ( e_theta.imag, r"$\operatorname{Im}(E_\theta)$", "signed", "theta", ), "phi_phase": ( numpy.angle(e_phi), r"$\arg(E_\phi)$", "phase", "phi", ), "theta_phase": ( numpy.angle(e_theta), r"$\arg(E_\theta)$", "phase", "theta", ), "phase_difference": ( self._wrap_phase(numpy.angle(e_theta) - numpy.angle(e_phi)), r"$\arg(E_\theta) - \arg(E_\phi)$", "phase", None, ), } if field not in field_map: raise ValueError( "Unknown far-field field. Expected one of: " f"{', '.join(field_map)}." ) return field_map[field] def _get_normalization(self, field: numpy.ndarray, field_type: str, scale: str, percentile_clip: float | None): if not numpy.isfinite(field).any(): return colors.Normalize(vmin=0.0, vmax=1.0) if field_type == "phase": return colors.Normalize(vmin=-numpy.pi, vmax=numpy.pi) if field_type == "signed": return signed_normalization(field, percentile_clip) if field_type == "intensity": return intensity_normalization(field, scale, percentile_clip) finite = field[numpy.isfinite(field)] return colors.Normalize(vmin=numpy.min(finite), vmax=numpy.max(finite)) def _resolve_colormap(self, colormap, field_type: str): return resolve_colormap(blue_black_red if colormap is None else colormap) def _make_pyvista_style_angle_mesh( self, n_points: int, ) -> tuple[numpy.ndarray, numpy.ndarray]: """ Build the same angular sampling convention used by the previous PyVista vector overlay. PyVista calls used: ``theta = numpy.linspace(0, 360, n_points)`` ``phi = numpy.linspace(180, 0, n_points)`` The arrays returned here are in radians. """ theta_degrees = numpy.linspace(0.0, 360.0, n_points) phi_degrees = numpy.linspace(180.0, 0.0, n_points) theta_radians = numpy.deg2rad(theta_degrees) phi_radians = numpy.deg2rad(phi_degrees) return numpy.meshgrid( theta_radians, phi_radians, indexing="ij", ) def _spherical_to_cartesian( self, theta: numpy.ndarray, phi: numpy.ndarray, radius: float, ) -> tuple[numpy.ndarray, numpy.ndarray, numpy.ndarray]: """ Convert PyVista-style spherical coordinates to Cartesian coordinates. ``theta`` is azimuthal and ``phi`` is polar. """ x_coordinates = radius * numpy.sin(phi) * numpy.cos(theta) y_coordinates = radius * numpy.sin(phi) * numpy.sin(theta) z_coordinates = radius * numpy.cos(phi) return x_coordinates, y_coordinates, z_coordinates def _complex_field_array( self, value, ) -> numpy.ndarray: """ Convert a complex Pint quantity or array-like object to a square array. The transpose reproduces the scalar ordering used by the previous PyVista implementation, where fields were attached to the ``StructuredGrid`` as ``field.flatten(order="F")``. """ return self._as_square_array( self._quantity_to_magnitude_array(value) ).T def _wrap_phase( self, phase: numpy.ndarray, ) -> numpy.ndarray: """ Wrap a phase-difference array to the interval ``[-pi, pi]``. ``numpy.angle`` already returns individual phases in this interval. This helper is used only for phase differences, where direct subtraction can otherwise create artificial jumps near the branch cut. """ return (phase + numpy.pi) % (2.0 * numpy.pi) - numpy.pi