@@ -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
13131557class FieldData (FieldDataset , ElectromagneticFieldData ):
13141558 """
0 commit comments