Skip to content

Commit b4f9377

Browse files
committed
fix(mode): change flux and dot methods to avoid interpolation when possible
more care truncating cells at monitor boundary fix source/monitor flux agreement
1 parent 7db47cf commit b4f9377

File tree

2 files changed

+253
-1
lines changed

2 files changed

+253
-1
lines changed

tidy3d/components/data/monitor_data.py

Lines changed: 245 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,74 @@ def _tangential_dims(self) -> list[str]:
450450

451451
return tangential_dims
452452

453+
@cached_property
454+
def _yee_integration_sizes(self) -> tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
455+
"""Return primal (cell) and dual step sizes for Yee grid integration.
456+
457+
The sizes are clamped to the monitor bounds, so cells partially outside the monitor
458+
have reduced sizes, and cells completely outside have size 0.
459+
460+
For Yee grid flux integration:
461+
- E1×H2 term uses: cell_dim1 (primal) × dual_dim2
462+
- E2×H1 term uses: dual_dim1 × cell_dim2 (primal)
463+
464+
Returns
465+
-------
466+
tuple[dict[str, np.ndarray], dict[str, np.ndarray]]
467+
A tuple of (cell_sizes, dual_sizes) dictionaries with monitor-bounds-adjusted
468+
step sizes for each tangential dimension.
469+
"""
470+
471+
# cell_sizes = self.grid_expanded._primal_steps
472+
# dual_sizes = self.grid_expanded._dual_steps
473+
474+
_, plane_inds = self.monitor.pop_axis([0, 1, 2], self.monitor.size.index(0.0))
475+
dims = ["x", "y", "z"]
476+
mnt_bounds = np.array(self.monitor.bounds)
477+
478+
cell_sizes = {}
479+
dual_sizes = {}
480+
481+
for axis in plane_inds:
482+
dim = dims[axis]
483+
mnt_min = mnt_bounds[0, axis]
484+
mnt_max = mnt_bounds[1, axis]
485+
centers = self._tangential_fields["E" + dim].coords[dim].values
486+
centers = np.concatenate([[mnt_min], centers])
487+
boundaries = self._tangential_fields["H" + dim].coords[dim].values
488+
boundaries = np.concatenate([boundaries, [mnt_max]])
489+
490+
# Dual sizes: distances between cell centers, clamped to monitor bounds
491+
# Build coords array: [first_boundary, centers]
492+
# Need an extract dummy value to get the correct length of dual_sizes, the
493+
# first dual step is partially outside the monitor bounds.
494+
dual_coords = np.clip(centers, mnt_min, mnt_max)
495+
dual_sizes[dim] = dual_coords[1:] - dual_coords[:-1]
496+
497+
# Cell/primal sizes: distances between boundaries, clamped to monitor bounds
498+
cell_coords = np.clip(boundaries, mnt_min, mnt_max)
499+
cell_sizes[dim] = cell_coords[1:] - cell_coords[:-1]
500+
501+
# boundaries = self.grid_expanded.boundaries.to_dict[dim]
502+
# centers = self.grid_expanded.centers.to_dict[dim]
503+
504+
# dim_idx = dims.index(dim)
505+
506+
# # Dual sizes: distances between cell centers, clamped to monitor bounds
507+
# # Build coords array: [first_boundary, centers]
508+
# # Need an extract dummy value to get the correct length of dual_sizes, the
509+
# # first dual step is partially outside the monitor bounds.
510+
# dual_coords = np.concatenate([[boundaries[0]], centers])
511+
# dual_coords = np.clip(dual_coords, mnt_min, mnt_max)
512+
# dual_sizes[dim] = dual_coords[1:] - dual_coords[:-1]
513+
514+
# # Cell/primal sizes: distances between boundaries, clamped to monitor bounds
515+
# cell_coords = boundaries
516+
# cell_coords = np.clip(cell_coords, mnt_min, mnt_max)
517+
# cell_sizes[dim] = cell_coords[1:] - cell_coords[:-1]
518+
519+
return cell_sizes, dual_sizes
520+
453521
@property
454522
def colocation_boundaries(self) -> Coords:
455523
"""Coordinates to be used for colocation of the data to grid boundaries."""
@@ -696,7 +764,8 @@ def package_flux_results(self, flux_values: DataArray) -> Any:
696764
@cached_property
697765
def complex_flux(self) -> Union[FluxDataArray, FreqModeDataArray]:
698766
"""Flux for data corresponding to a 2D monitor."""
699-
767+
if self.monitor.colocate is False:
768+
return self._complex_flux_yee
700769
# Compute flux by integrating Poynting vector in-plane
701770
d_area = self._diff_area
702771
poynting = self.complex_poynting
@@ -711,6 +780,48 @@ def flux(self) -> Union[FluxDataArray, FreqModeDataArray]:
711780
"""Flux for data corresponding to a 2D monitor."""
712781
return self.complex_flux.real
713782

783+
@cached_property
784+
def _complex_flux_yee(self) -> FluxDataArray:
785+
"""Flux for data corresponding to a 2D monitor."""
786+
787+
# Tangential fields are ordered as E1, E2, H1, H2
788+
self._check_suitability_for_flux_yee()
789+
tan_fields = self._tangential_fields
790+
dim1, dim2 = self._tangential_dims
791+
E1 = tan_fields["E" + dim1]
792+
E2 = tan_fields["E" + dim2]
793+
H1 = tan_fields["H" + dim1]
794+
H2 = tan_fields["H" + dim2]
795+
796+
e_x_h_pos1 = E1 * H2.conj()
797+
e_x_h_pos2 = E2 * H1.conj()
798+
799+
# Get boundary-adjusted cell sizes for proper integration weights
800+
cell_sizes, dual_cell_sizes = self._yee_integration_sizes
801+
802+
# Create DataArrays for cell sizes - xarray broadcasts over other dims automatically
803+
da_cell_dim1 = xr.DataArray(cell_sizes[dim1], dims=[dim1], coords={dim1: E1.coords[dim1]})
804+
da_dual_dim1 = xr.DataArray(
805+
dual_cell_sizes[dim1], dims=[dim1], coords={dim1: E2.coords[dim1]}
806+
)
807+
da_cell_dim2 = xr.DataArray(cell_sizes[dim2], dims=[dim2], coords={dim2: E2.coords[dim2]})
808+
da_dual_dim2 = xr.DataArray(
809+
dual_cell_sizes[dim2], dims=[dim2], coords={dim2: E1.coords[dim2]}
810+
)
811+
812+
E1_H2_darea = da_cell_dim1 * da_dual_dim2
813+
E2_H1_darea = da_dual_dim1 * da_cell_dim2
814+
815+
term1 = (e_x_h_pos1 * E1_H2_darea).sum(dim=[dim1, dim2])
816+
term2 = -(e_x_h_pos2 * E2_H1_darea).sum(dim=[dim1, dim2])
817+
818+
return FluxDataArray((term1 + term2) / 2)
819+
820+
@cached_property
821+
def _flux_yee(self) -> FluxDataArray:
822+
"""Flux for data corresponding to a 2D monitor."""
823+
return self._complex_flux_yee.real
824+
714825
@cached_property
715826
def mode_area(self) -> FreqModeDataArray:
716827
r"""Effective mode area corresponding to a 2D monitor.
@@ -767,6 +878,8 @@ def dot(
767878
In the non-conjugated definition, modes are orthogonal, but the interpretation of the
768879
dot product power carried by a given mode is no longer valid.
769880
"""
881+
if self.monitor.colocate is False and field_data.monitor.colocate is False:
882+
return self._dot_yee(field_data, conjugate=conjugate)
770883

771884
# Tangential fields for current and other field data
772885
fields_self = self._colocated_tangential_fields
@@ -820,6 +933,95 @@ def _tangential_fields_match_coords(self, coords: ArrayFloat2D) -> bool:
820933
return False
821934
return True
822935

936+
def _dot_yee(
937+
self, field_data: Union[FieldData, ModeData, ModeSolverData], conjugate: bool = True
938+
) -> ModeAmpsDataArray:
939+
r"""Dot product (modal overlap) with another :class:`.FieldData` object. Both datasets have
940+
to be frequency-domain data associated with a 2D monitor. Along the tangential directions,
941+
the datasets have to have the same discretization. Along the normal direction, the monitor
942+
position may differ and is ignored. Other coordinates (``frequency``, ``mode_index``) have
943+
to be either identical or broadcastable. Broadcasting is also supported in the case in
944+
which the other ``field_data`` has a dimension of size 1 whose coordinate is not in the list
945+
of coordinates in the ``self`` dataset along the corresponding dimension. In that case, the
946+
coordinates of the ``self`` dataset are used in the output.
947+
948+
The dot product is defined as:
949+
950+
.. math:
951+
952+
\frac{1}{4} \int \left( E_0 \times H_1^* + H_0^* \times E_1 \) \, {\rm d}S
953+
954+
Parameters
955+
----------
956+
field_data : :class:`ElectromagneticFieldData`
957+
A data instance to compute the dot product with.
958+
conjugate : bool, optional
959+
If ``True`` (default), the dot product is defined as above. If ``False``, the definition
960+
is similar, but without the complex conjugation of the $H$ fields.
961+
962+
Note
963+
----
964+
The dot product with and without conjugation is equivalent (up to a phase) for
965+
modes in lossless waveguides but differs for modes in lossy materials. In that case,
966+
the conjugated dot product can be interpreted as the fraction of the power of the first
967+
mode carried by the second, but modes are not orthogonal with respect to that product
968+
and the sum of carried power fractions may be different from the total flux.
969+
In the non-conjugated definition, modes are orthogonal, but the interpretation of the
970+
dot product power carried by a given mode is no longer valid.
971+
"""
972+
973+
# Tangential fields for current and other field data
974+
# fields_self = self._colocated_tangential_fields
975+
fields_self = self._tangential_fields
976+
# fields_self = {key: field.isel(z=0, drop=True) for key, field in self.field_components.items()}
977+
978+
if conjugate:
979+
fields_self = {key: field.conj() for key, field in fields_self.items()}
980+
981+
# fields_other = field_data._interpolated_tangential_fields(self._plane_grid_boundaries)
982+
fields_other = field_data._tangential_fields
983+
984+
# Drop size-1 dimensions in the other data
985+
fields_other = {key: field.squeeze(drop=True) for key, field in fields_other.items()}
986+
987+
# Cross products of fields
988+
dim1, dim2 = self._tangential_dims
989+
990+
E1xH2 = (
991+
fields_self["E" + dim1] * fields_other["H" + dim2]
992+
+ fields_self["H" + dim2] * fields_other["E" + dim1]
993+
)
994+
E2xH1 = (
995+
fields_self["E" + dim2] * fields_other["H" + dim1]
996+
+ fields_self["H" + dim1] * fields_other["E" + dim2]
997+
)
998+
999+
# Get boundary-adjusted cell sizes for proper integration weights
1000+
cell_sizes, dual_cell_sizes = self._yee_integration_sizes
1001+
1002+
# Create DataArrays for cell sizes - xarray broadcasts over other dims automatically
1003+
# E1xH2 uses: cell_dim1 (E1 coord) × dual_dim2 (H2 coord, same as E1)
1004+
da_cell_dim1 = xr.DataArray(
1005+
cell_sizes[dim1], dims=[dim1], coords={dim1: fields_self["E" + dim1].coords[dim1]}
1006+
)
1007+
da_dual_dim2 = xr.DataArray(
1008+
dual_cell_sizes[dim2], dims=[dim2], coords={dim2: fields_self["E" + dim1].coords[dim2]}
1009+
)
1010+
# E2xH1 uses: dual_dim1 (E2 coord) × cell_dim2 (H1 coord, same as E2)
1011+
da_dual_dim1 = xr.DataArray(
1012+
dual_cell_sizes[dim1], dims=[dim1], coords={dim1: fields_self["E" + dim2].coords[dim1]}
1013+
)
1014+
da_cell_dim2 = xr.DataArray(
1015+
cell_sizes[dim2], dims=[dim2], coords={dim2: fields_self["E" + dim2].coords[dim2]}
1016+
)
1017+
1018+
E1_H2_dS = da_cell_dim1 * da_dual_dim2
1019+
E2_H1_dS = da_dual_dim1 * da_cell_dim2
1020+
1021+
term1 = (E1xH2 * E1_H2_dS).sum(dim=[dim1, dim2])
1022+
term2 = -(E2xH1 * E2_H1_dS).sum(dim=[dim1, dim2])
1023+
return ModeAmpsDataArray(0.25 * (term1 + term2))
1024+
8231025
def _interpolated_tangential_fields(self, coords: ArrayFloat2D) -> dict[str, DataArray]:
8241026
"""For 2D monitors, interpolate this fields to given coords in the tangential plane.
8251027
@@ -1309,6 +1511,48 @@ def _interpolated_copies_if_needed(
13091511
other_copy = other.interpolated_copy if isinstance(other, ModeSolverData) else other
13101512
return self_copy, other_copy
13111513

1514+
def _check_suitability_for_flux_yee(self):
1515+
"""Check if fields come from uncolocated type monitor and can be used with flux_yee."""
1516+
# Tangential fields are ordered as E1, E2, H1, H2
1517+
dim1, dim2 = self._tangential_dims
1518+
E1 = self._tangential_fields["E" + dim1]
1519+
E2 = self._tangential_fields["E" + dim2]
1520+
H1 = self._tangential_fields["H" + dim1]
1521+
H2 = self._tangential_fields["H" + dim2]
1522+
1523+
# Check that E1/H2 and E2/H1 coordinates match
1524+
eh_1_match = all(E1.coords[c].equals(H2.coords[c]) for c in self._tangential_dims)
1525+
eh_2_match = all(E2.coords[c].equals(H1.coords[c]) for c in self._tangential_dims)
1526+
if not (eh_1_match and eh_2_match):
1527+
raise ValueError("Cannot use '_flux_yee' on the provided field data.")
1528+
1529+
# Check that field array lengths match the grid sizes
1530+
primal_sizes, dual_sizes = self._yee_integration_sizes
1531+
1532+
# E1 uses primal in dim1, dual in dim2
1533+
if len(E1.coords[dim1]) != len(primal_sizes[dim1]):
1534+
raise ValueError(
1535+
f"Field coordinate length ({len(E1.coords[dim1])}) does not match "
1536+
f"grid primal size length ({len(primal_sizes[dim1])}) in dimension '{dim1}'."
1537+
)
1538+
if len(E1.coords[dim2]) != len(dual_sizes[dim2]):
1539+
raise ValueError(
1540+
f"Field coordinate length ({len(E1.coords[dim2])}) does not match "
1541+
f"grid dual size length ({len(dual_sizes[dim2])}) in dimension '{dim2}'."
1542+
)
1543+
1544+
# E2 uses dual in dim1, primal in dim2
1545+
if len(E2.coords[dim1]) != len(dual_sizes[dim1]):
1546+
raise ValueError(
1547+
f"Field coordinate length ({len(E2.coords[dim1])}) does not match "
1548+
f"grid dual size length ({len(dual_sizes[dim1])}) in dimension '{dim1}'."
1549+
)
1550+
if len(E2.coords[dim2]) != len(primal_sizes[dim2]):
1551+
raise ValueError(
1552+
f"Field coordinate length ({len(E2.coords[dim2])}) does not match "
1553+
f"grid primal size length ({len(primal_sizes[dim2])}) in dimension '{dim2}'."
1554+
)
1555+
13121556

13131557
class FieldData(FieldDataset, ElectromagneticFieldData):
13141558
"""

tidy3d/components/monitor.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,14 @@ class SurfaceIntegrationMonitor(Monitor, ABC):
658658
description="Surfaces to exclude in the integration, if a volume monitor.",
659659
)
660660

661+
colocate: bool = pydantic.Field(
662+
True,
663+
title="Colocate Fields",
664+
description="Defines whether fields are colocated to grid cell boundaries (i.e. to the "
665+
"primal grid). Can be toggled for field recording monitors and is hard-coded for other "
666+
"monitors depending on their specific function.",
667+
)
668+
661669
@property
662670
def integration_surfaces(self):
663671
"""Surfaces of the monitor where fields will be recorded for subsequent integration."""

0 commit comments

Comments
 (0)