"""Finite refractive-index fields on centred cubic voxel grids."""
from copy import deepcopy
from dataclasses import dataclass, field
import numpy as np
from numpy.typing import ArrayLike
from types import NotImplementedType
from typing import TYPE_CHECKING, Literal, Self
from .grid import Grid
from .medium import Medium
from .units import _integer, _refractive_index_values, Quantity
if TYPE_CHECKING:
from matplotlib.figure import Figure as MatplotlibFigure
from plotly.graph_objects import Figure as PlotlyFigure
[docs]
@dataclass(frozen=True, kw_only=True)
class Volume:
"""Store a finite voxel field and its explicit spatial Grid.
The copied, read-only delta_refractive_index array has shape grid.shape.
``refractive_index`` returns the read-only absolute field
``background_refractive_index + delta_refractive_index``. Addition combines
contrasts from volumes only when their grids and backgrounds match.
Background refractive index is a plain number; grid spacing and positions
carry physical units. Relative permittivity is linearized as
background_refractive_index**2 + 2*background_refractive_index*delta_refractive_index.
The quadratic fluctuation term is omitted at every Born order.
Medium.to_volume records generation statistics and seed. ``medium_metadata``
returns the metadata snapshot captured when the volume was created. Manually
constructed volumes have only their explicitly supplied provenance.
Use :meth:`from_refractive_index` when the input array contains absolute
refractive indices rather than fluctuations from the background.
"""
delta_refractive_index: np.ndarray
background_refractive_index: float
grid: Grid
spacing: Quantity = field(init=False)
medium: Medium | None = None
seed: int | None = None
_medium_metadata: dict[str, object] | None = field(init=False, default=None, repr=False, compare=False)
def __post_init__(self) -> None:
data = np.array(
_refractive_index_values(
value=self.delta_refractive_index,
name="delta_refractive_index",
),
copy=True,
)
if data.ndim != 3 or any(n < 2 or n > 32 for n in data.shape) or not np.all(np.isfinite(data)):
raise ValueError("delta_refractive_index must be a finite 3D array with 2–32 cells per axis.")
grid = Grid._resolve(grid=self.grid)
if grid.shape != data.shape:
raise ValueError("grid shape must match delta_refractive_index.")
object.__setattr__(self, "grid", grid)
object.__setattr__(self, "spacing", grid.spacing)
object.__setattr__(
self,
"background_refractive_index",
_refractive_index_values(
value=self.background_refractive_index,
name="background_refractive_index",
scalar=True,
),
)
if not np.isfinite(self.background_refractive_index) or self.background_refractive_index <= 0:
raise ValueError("background_refractive_index must be finite and positive.")
if np.any(self.background_refractive_index**2 + 2 * self.background_refractive_index * data <= 0):
raise ValueError("Linearized relative permittivity must stay positive.")
if (self.medium is None) != (self.seed is None):
raise ValueError("medium and seed must be supplied together.")
if self.medium is not None:
if not isinstance(self.medium, Medium):
raise TypeError("medium must be a Medium.")
if self.medium.background_refractive_index != self.background_refractive_index:
raise ValueError("medium background_refractive_index must match the volume.")
object.__setattr__(
self,
"seed",
_integer(
value=self.seed,
name="seed",
low=0,
high=2**32 - 1,
),
)
object.__setattr__(
self,
"_medium_metadata",
deepcopy(self.medium.metadata) if self.medium is not None else None,
)
data.setflags(write=False)
object.__setattr__(self, "delta_refractive_index", data)
[docs]
@classmethod
def from_refractive_index(
cls,
*,
refractive_index: ArrayLike,
background_refractive_index: float,
grid: Grid,
medium: Medium | None = None,
seed: int | None = None,
) -> Self:
"""Construct a volume from an absolute refractive-index field.
Parameters
----------
refractive_index : array_like
Plain numerical absolute refractive indices, with shape
``grid.shape``. Values must not carry units.
background_refractive_index : float
Positive, unitless reference refractive index.
grid : Grid
Explicit spatial grid matching the field shape.
medium : Medium, optional
Medium metadata associated with generation. Must be supplied
together with ``seed``.
seed : int, optional
Generation seed, supplied together with ``medium``.
Returns
-------
Volume
Immutable volume storing the field as
``refractive_index - background_refractive_index``.
Raises
------
ValueError
If the field, background, grid shape, or resulting linearized
permittivity is invalid.
TypeError
If ``medium`` is not a Medium instance.
"""
absolute_refractive_index = _refractive_index_values(
value=refractive_index,
name="refractive_index",
)
background = _refractive_index_values(
value=background_refractive_index,
name="background_refractive_index",
scalar=True,
)
return cls(
delta_refractive_index=absolute_refractive_index - background,
background_refractive_index=background,
grid=grid,
medium=medium,
seed=seed,
)
@property
def positions(self) -> Quantity:
"""Return voxel-centre coordinates relative to the sample centre.
Returns
-------
positions : Quantity
Unit-bearing coordinates, shape (nx, ny, nz, 3), with the final
axis ordered as x, y, z.
"""
return getattr(self, "grid").positions
@property
def volume(self) -> Quantity:
"""Return the total physical volume of all voxels.
Returns
-------
volume : Quantity
Unit-bearing sample volume, equal to ``delta_refractive_index.size*spacing**3``.
"""
return getattr(self, "grid").volume
@property
def refractive_index(self) -> np.ndarray:
"""Return the read-only absolute refractive-index field.
Returns
-------
numpy.ndarray
Absolute refractive indices, equal to the background plus
``delta_refractive_index``.
"""
data = self.background_refractive_index + self.delta_refractive_index
data.setflags(write=False)
return data
@property
def medium_metadata(self) -> dict[str, object] | None:
"""Return a copy of the generation metadata captured at construction.
Returns
-------
dict[str, object] or None
A fresh copy of the source medium's metadata, or None when the
volume has no associated medium.
"""
return deepcopy(self._medium_metadata)
def __add__(self, other: object) -> "Volume | NotImplementedType":
"""Add refractive-index contrasts from volumes on the same grid.
Both volumes must have the same grid and background refractive index.
The resulting volume contains no generation provenance because it is
a manual composition of its inputs.
"""
if not isinstance(other, Volume):
return NotImplemented
if self.grid != other.grid:
raise ValueError("Volumes must use the same grid to be added.")
if self.background_refractive_index != other.background_refractive_index:
raise ValueError("Volumes must use the same background_refractive_index to be added.")
return Volume(
delta_refractive_index=self.delta_refractive_index + other.delta_refractive_index,
background_refractive_index=self.background_refractive_index,
grid=self.grid,
)
[docs]
def plot_slice(
self,
*,
normal: Literal["x", "y", "z"] = "z",
index: int | None = None,
field: Literal["refractive_index", "delta_refractive_index", "permittivity"] = "refractive_index",
length_unit: str = "nanometer",
title: str | None = None,
) -> "MatplotlibFigure":
"""Build a Matplotlib figure of one voxel plane.
Parameters
----------
normal : {'x', 'y', 'z'}, optional
Axis perpendicular to the plane; default 'z'.
index : int, optional
Voxel index along the normal. Defaults to n//2, the positive
central plane for an even grid. The title gives its actual position.
field : {'refractive_index', 'delta_refractive_index', 'permittivity'}, optional
Display n0 + delta_refractive_index, fluctuations, or linearized relative
permittivity n0**2 + 2*n0*delta_refractive_index. Default 'refractive_index'.
length_unit : str, optional
Spatial display unit; default 'nanometer'. Stored SI data is unchanged.
title : str, optional
Override the title describing the field and plane position.
Returns
-------
figure : matplotlib.figure.Figure
Equal-aspect image with voxel-edge extents and a labelled colorbar.
Call pyplot.show() to display or figure.savefig() to export.
Colors use the full volume's range to compare different planes;
fluctuation colors are symmetric about zero.
Raises
------
ValueError
If the plane, index, field, or length unit is invalid.
"""
import matplotlib.pyplot as plt
if normal not in ("x", "y", "z"):
raise ValueError("normal must be x, y, or z.")
axis_index = "xyz".index(normal)
count = self.delta_refractive_index.shape[axis_index]
selected = _integer(
value=count // 2 if index is None else index,
name="index",
low=0,
high=count - 1,
)
values, label = self._plot_field(field=field)
spacing = self._plot_spacing(length_unit=length_unit)
unit = f"{spacing.units:~}"
width = float(spacing.magnitude)
horizontal, vertical = [i for i in range(3) if i != axis_index]
half_width = self.delta_refractive_index.shape[horizontal] * width / 2
half_height = self.delta_refractive_index.shape[vertical] * width / 2
color_low, color_high, _ = self._plot_color_limits(values=values, field=field)
figure, axis = plt.subplots(layout="constrained")
image = axis.imshow(
np.take(values, selected, axis=axis_index).T,
origin="lower",
extent=(-half_width, half_width, -half_height, half_height),
interpolation="nearest",
aspect="equal",
cmap="RdBu_r" if field == "delta_refractive_index" else "viridis",
vmin=color_low,
vmax=color_high,
)
position = (selected - (count - 1) / 2) * width
axis.set(
xlabel=f"{'xyz'[horizontal]} ({unit})",
ylabel=f"{'xyz'[vertical]} ({unit})",
title=title if title is not None else f"{label} at {normal} = {position:+g} {unit}",
)
figure.colorbar(image, ax=axis, label=label)
return figure
[docs]
def plot_3d(
self,
*,
backend: Literal["matplotlib", "plotly"] = "plotly",
mode: Literal["voxels", "volume", "isosurface"] | None = None,
field: Literal["delta_refractive_index", "refractive_index", "permittivity"] = "delta_refractive_index",
length_unit: str = "nanometer",
surface_count: int = 8,
opacity: float | int | None = None,
opacity_scale: Literal["uniform", "increasing"] = "uniform",
) -> "MatplotlibFigure | PlotlyFigure":
"""Build a three-dimensional figure of the finite voxel medium.
Parameters
----------
backend : {'matplotlib', 'plotly'}, optional
Default is Plotly for browser interaction. Select 'matplotlib'
explicitly for a Matplotlib figure.
mode : str, optional
Matplotlib supports 'voxels' (default). Plotly supports 'volume'
(default) and 'isosurface'. Uniform Plotly fields are rendered as
volume data; an isosurface cannot be generated from a uniform field.
field : {'delta_refractive_index', 'refractive_index', 'permittivity'}, optional
Scalar field to display, default refractive index fluctuation. 'refractive_index' displays
n0 + delta_refractive_index; 'permittivity' displays the solver's linearized
relative permittivity n0**2 + 2*n0*delta_refractive_index, not (n0+delta_refractive_index)**2.
length_unit : str, optional
Spatial display unit, default 'nanometer'. Stored SI data is unchanged.
surface_count : int, optional
Number of contours, from 1 to 32; default 8. More surfaces increase
browser rendering cost. Used only by Plotly volume and isosurface modes.
opacity : float, optional
Surface opacity in (0, 1]. Default is 1 for Matplotlib and 0.15
for Plotly.
opacity_scale : {'uniform', 'increasing'}, optional
Default 'uniform' gives all contours the same opacity. 'increasing'
is available only for Plotly volume mode and scales opacity from
zero at the lowest displayed contour to ``opacity`` at the highest.
Use field='refractive_index' to emphasize high refractive index, rather than
the magnitude of positive and negative fluctuations.
Returns
-------
figure : matplotlib.figure.Figure or plotly.graph_objects.Figure
Matplotlib: call pyplot.show() to display or figure.savefig() to
export. Rotation needs an interactive Matplotlib backend; gallery
images are static. Plotly: call figure.show() or figure.write_html().
Constructing a figure opens no window or browser.
Raises
------
ValueError
If a mode, scalar field, display unit, or rendering setting is invalid.
Notes
-----
Matplotlib voxels preserve staircase interfaces. Dense transparent
scenes can have depth-ordering limitations in Matplotlib. Plotly
contours interpolate between voxel centres and are visualization aids,
not exact curved interfaces or a replacement for grid refinement.
Aspect ratio follows physical dimensions. All voxels are retained, with
at most 32**3 samples under Volume's grid limit; browser performance
also depends on surface count and graphics support. This plots the
input material, not an electromagnetic field.
"""
if backend not in ("matplotlib", "plotly"):
raise ValueError("backend must be matplotlib or plotly.")
if mode is None:
mode = "voxels" if backend == "matplotlib" else "volume"
if backend == "matplotlib" and mode != "voxels":
raise ValueError("matplotlib mode must be voxels.")
if backend == "plotly" and mode not in ("volume", "isosurface"):
raise ValueError("plotly mode must be volume or isosurface.")
if opacity_scale not in ("uniform", "increasing"):
raise ValueError("opacity_scale must be uniform or increasing.")
if opacity_scale != "uniform" and (backend != "plotly" or mode != "volume"):
raise ValueError("increasing opacity_scale is only supported for Plotly volume mode.")
values, label = self._plot_field(field=field)
count = _integer(
value=surface_count,
name="surface_count",
low=1,
high=32,
)
if opacity is None:
opacity = 1.0 if backend == "matplotlib" else 0.15
if isinstance(opacity, (bool, np.bool_)) or not isinstance(opacity, (int, float, np.integer, np.floating)):
raise ValueError("opacity must be finite and in (0, 1].")
alpha = float(opacity)
if not np.isfinite(alpha) or not 0 < alpha <= 1:
raise ValueError("opacity must be finite and in (0, 1].")
spacing = self._plot_spacing(length_unit=length_unit)
positions = self.positions.to(spacing.units).magnitude
low, high = float(values.min()), float(values.max())
color_low, color_high, uniform = self._plot_color_limits(values=values, field=field)
if uniform and backend == "plotly" and mode == "isosurface":
raise ValueError("An isosurface cannot be generated from a uniform field; use mode='volume'.")
if backend == "matplotlib":
return self._plot_3d_matplotlib(
values=values,
label=label,
positions=positions,
length_unit=length_unit,
color_low=color_low,
color_high=color_high,
colormap="RdBu_r" if field == "delta_refractive_index" else "viridis",
opacity=alpha,
uniform=uniform,
)
if mode == "volume":
plotly_mode: Literal["volume", "isosurface"] = "volume"
elif mode == "isosurface":
plotly_mode = "isosurface"
else:
raise ValueError("plotly mode must be volume or isosurface.")
return self._plot_3d_plotly(
mode=plotly_mode,
field=field,
values=values,
label=label,
positions=positions,
length_unit=length_unit,
surface_count=count,
opacity=alpha,
opacity_scale=opacity_scale,
low=low,
high=high,
color_low=color_low,
color_high=color_high,
uniform=uniform,
)
def _plot_spacing(self, *, length_unit: str) -> Quantity:
"""Convert voxel spacing to a display unit or raise a domain error."""
from pint.errors import DimensionalityError, UndefinedUnitError
try:
return self.spacing.to(length_unit)
except (DimensionalityError, UndefinedUnitError, TypeError, ValueError) as error:
raise ValueError("length_unit must name a length unit.") from error
@staticmethod
def _plot_color_limits(
*,
values: np.ndarray,
field: Literal["delta_refractive_index", "refractive_index", "permittivity"],
) -> tuple[float, float, bool]:
"""Return a shared color range and whether the field is uniform."""
low, high = float(values.min()), float(values.max())
uniform = low == high
if field == "delta_refractive_index":
extent = float(np.abs(values).max())
low, high = -extent, extent
if low == high:
padding = max(abs(low), 1.0) * 1e-6
low, high = low - padding, high + padding
return low, high, uniform
def _plot_3d_plotly(
self,
*,
mode: Literal["volume", "isosurface"],
field: Literal["delta_refractive_index", "refractive_index", "permittivity"],
values: np.ndarray,
label: str,
positions: np.ndarray,
length_unit: str,
surface_count: int,
opacity: float,
opacity_scale: Literal["uniform", "increasing"],
low: float,
high: float,
color_low: float,
color_high: float,
uniform: bool,
) -> "PlotlyFigure":
"""Render volume or isosurface traces using Plotly."""
import plotly.graph_objects as go
colorbar = {"title": label, **({"tickvals": [low]} if uniform else {})}
hover = (
f"x=%{{x:.4g}} {length_unit}<br>y=%{{y:.4g}} {length_unit}"
f"<br>z=%{{z:.4g}} {length_unit}<br>{label}=%{{value:.5g}}<extra></extra>"
)
trace = go.Volume if mode == "volume" else go.Isosurface
opacity_options = {"opacityscale": [[0, 0], [1, 1]]} if opacity_scale == "increasing" else {}
traces = [
trace(
x=positions[..., 0].ravel(),
y=positions[..., 1].ravel(),
z=positions[..., 2].ravel(),
value=values.ravel(),
isomin=color_low if uniform else low + (high - low) * 0.05,
isomax=color_high if uniform else high - (high - low) * 0.05,
cmin=color_low,
cmax=color_high,
surface_count=surface_count,
opacity=opacity,
colorscale="RdBu_r" if field == "delta_refractive_index" else "Viridis",
colorbar=colorbar,
caps={"x_show": False, "y_show": False, "z_show": False},
hovertemplate=hover,
**opacity_options,
)
]
figure = go.Figure(data=traces)
figure.update_layout(
title=f"{label}: {mode}" + (" (uniform field)" if uniform else ""),
scene={
"xaxis_title": f"x ({length_unit})",
"yaxis_title": f"y ({length_unit})",
"zaxis_title": f"z ({length_unit})",
"aspectmode": "data",
},
margin={"l": 0, "r": 0, "b": 0, "t": 45},
)
return figure
def _plot_field(
self,
*,
field: Literal["delta_refractive_index", "refractive_index", "permittivity"],
) -> tuple[np.ndarray, str]:
"""Return the selected material field and display label."""
fields = {
"delta_refractive_index": (self.delta_refractive_index, "Refractive-index fluctuation δn"),
"refractive_index": (self.refractive_index, "Refractive index n"),
"permittivity": (
self.background_refractive_index**2
+ 2 * self.background_refractive_index * self.delta_refractive_index,
"Linearized relative permittivity",
),
}
if field not in fields:
raise ValueError("field must be delta_refractive_index, refractive_index, or permittivity.")
return fields[field]
def _plot_3d_matplotlib(
self,
*,
values: np.ndarray,
label: str,
positions: np.ndarray,
length_unit: str,
color_low: float,
color_high: float,
colormap: str,
opacity: float,
uniform: bool,
) -> "MatplotlibFigure":
"""Render sampled cells with physical extents and a shared color scale."""
import matplotlib.pyplot as plt
from matplotlib.cm import ScalarMappable
from matplotlib.colors import Normalize
shape = self.delta_refractive_index.shape
width = positions[1, 0, 0, 0] - positions[0, 0, 0, 0]
edges = [(np.arange(n + 1) - n / 2) * width for n in shape]
norm = Normalize(vmin=color_low, vmax=color_high)
cmap = plt.get_cmap(colormap)
figure = plt.figure(figsize=(8, 6), layout="constrained")
axis = figure.add_subplot(projection="3d")
filled = self.delta_refractive_index != 0
if not np.any(filled):
filled = np.ones(shape, dtype=bool)
voxel_coordinates = np.meshgrid(*edges, indexing="ij")
axis.voxels(
*voxel_coordinates,
filled=filled,
facecolors=cmap(norm(values)),
edgecolors=(0.1, 0.1, 0.1, 0.25),
linewidth=0.3,
shade=False,
alpha=opacity,
)
axis.set(
xlabel=f"x ({length_unit})",
ylabel=f"y ({length_unit})",
zlabel=f"z ({length_unit})",
xlim=(edges[0][0], edges[0][-1]),
ylim=(edges[1][0], edges[1][-1]),
zlim=(edges[2][0], edges[2][-1]),
title=f"{label}: voxels" + (" (uniform field)" if uniform else ""),
)
axis.set_box_aspect(shape)
mappable = ScalarMappable(norm=norm, cmap=cmap)
colorbar = figure.colorbar(mappable, ax=axis, label=label, shrink=0.65, pad=0.1)
if uniform:
colorbar.set_ticks([float(values.flat[0])])
return figure