Skip to content

Commit 676f351

Browse files
committed
add windowing feature to field projection monitors
- revisions based on PR 1275 review - update docstring and comment
1 parent d66306e commit 676f351

File tree

5 files changed

+212
-7
lines changed

5 files changed

+212
-7
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2929
- Ability to downsample recorded near fields to speed up server-side far field projections.
3030
- `FieldData.apply_phase(phase)` to multiply field data by a phase.
3131
- Optional `phase` argument to `SimulationData.plot_field` that applies a phase to complex-valued fields.
32+
- Ability to window near fields for spatial filtering of far fields for both client- and server-side field projections.
3233

3334
### Changed
3435
- Indent for the json string of Tidy3D models has been changed to `None` when used internally; kept as `indent=4` for writing to `json` and `yaml` files.

tests/sims/simulation_2_5_0rc3.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1706,6 +1706,10 @@
17061706
3.0
17071707
],
17081708
"far_field_approx": true,
1709+
"window_size": [
1710+
0.0,
1711+
0.0
1712+
],
17091713
"proj_distance": 1000000.0,
17101714
"theta": [
17111715
-1.5707963267948966,
@@ -1851,6 +1855,10 @@
18511855
3.0
18521856
],
18531857
"far_field_approx": true,
1858+
"window_size": [
1859+
0.0,
1860+
0.0
1861+
],
18541862
"proj_axis": 2,
18551863
"proj_distance": 5.0,
18561864
"x": [
@@ -1903,6 +1911,10 @@
19031911
3.0
19041912
],
19051913
"far_field_approx": true,
1914+
"window_size": [
1915+
0.0,
1916+
0.0
1917+
],
19061918
"proj_axis": 2,
19071919
"proj_distance": 1000000.0,
19081920
"ux": [
@@ -1952,6 +1964,10 @@
19521964
3.0
19531965
],
19541966
"far_field_approx": true,
1967+
"window_size": [
1968+
0.0,
1969+
0.0
1970+
],
19551971
"proj_distance": 1000000.0,
19561972
"theta": [
19571973
-1.5707963267948966,

tests/test_components/test_monitor.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,26 @@ def test_fieldproj_local_origin():
197197
M.local_origin
198198

199199

200+
def test_fieldproj_window():
201+
M = td.FieldProjectionAngleMonitor(
202+
size=(2, 0, 2), theta=[1, 2], phi=[0], name="f", freqs=[2e12], window_size=(0.2, 1)
203+
)
204+
window_size, window_minus, window_plus = M.window_parameters()
205+
window_size, window_minus, window_plus = M.window_parameters(M.bounds)
206+
points = np.linspace(0, 10, 100)
207+
_ = M.window_function(points, window_size, window_minus, window_plus, 2)
208+
# do not allow a window size larger than 1
209+
with pytest.raises(pydantic.ValidationError):
210+
_ = td.FieldProjectionAngleMonitor(
211+
size=(2, 0, 2), theta=[1, 2], phi=[0], name="f", freqs=[2e12], window_size=(0.2, 1.1)
212+
)
213+
# do not allow non-zero windows for volume monitors
214+
with pytest.raises(pydantic.ValidationError):
215+
_ = td.FieldProjectionAngleMonitor(
216+
size=(2, 1, 2), theta=[1, 2], phi=[0], name="f", freqs=[2e12], window_size=(0.2, 0)
217+
)
218+
219+
200220
PROJ_MNTS = [
201221
td.FieldProjectionAngleMonitor(size=(2, 0, 2), theta=[1, 2], phi=[0], name="f", freqs=[2e12]),
202222
td.FieldProjectionCartesianMonitor(

tidy3d/components/field_projection.py

Lines changed: 70 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,45 @@ def integrate_for_one_theta(i_th: int):
459459

460460
return Er, Etheta, Ephi, Hr, Htheta, Hphi
461461

462+
@staticmethod
463+
def apply_window_to_currents(
464+
proj_monitor: AbstractFieldProjectionMonitor, currents: xr.Dataset
465+
) -> xr.Dataset:
466+
"""Apply windowing function to the surface currents."""
467+
if proj_monitor.size.count(0.0) == 0:
468+
return currents
469+
if proj_monitor.window_size == (0, 0):
470+
return currents
471+
472+
pts = [currents[name].values for name in ["x", "y", "z"]]
473+
474+
custom_bounds = [
475+
[pts[i][0] for i in range(3)],
476+
[pts[i][-1] for i in range(3)],
477+
]
478+
479+
window_size, window_minus, window_plus = proj_monitor.window_parameters(
480+
custom_bounds=custom_bounds
481+
)
482+
483+
new_currents = currents.copy(deep=True)
484+
for dim, (dim_name, points) in enumerate(zip("xyz", pts)):
485+
window_fn = proj_monitor.window_function(
486+
points=points,
487+
window_size=window_size,
488+
window_minus=window_minus,
489+
window_plus=window_plus,
490+
dim=dim,
491+
)
492+
window_data = xr.DataArray(
493+
window_fn,
494+
dims=[dim_name],
495+
coords=[points],
496+
)
497+
new_currents *= window_data
498+
499+
return new_currents
500+
462501
def project_fields(
463502
self, proj_monitor: AbstractFieldProjectionMonitor
464503
) -> AbstractFieldProjectionData:
@@ -514,10 +553,17 @@ def _project_fields_angular(
514553

515554
for surface in self.surfaces:
516555

556+
# apply windowing to currents
557+
currents = self.apply_window_to_currents(monitor, self.currents[surface.monitor.name])
558+
517559
if monitor.far_field_approx:
518560
for idx_f, frequency in enumerate(freqs):
519561
_fields = self._far_fields_for_surface(
520-
frequency, theta, phi, surface, self.currents[surface.monitor.name]
562+
frequency=frequency,
563+
theta=theta,
564+
phi=phi,
565+
surface=surface,
566+
currents=currents,
521567
)
522568
for field, _field in zip(fields, _fields):
523569
field[..., idx_f] += _field * phase[idx_f]
@@ -534,7 +580,7 @@ def _project_fields_angular(
534580
):
535581
_x, _y, _z = monitor.sph_2_car(monitor.proj_distance, _theta, _phi)
536582
_fields = self._fields_for_surface_exact(
537-
_x, _y, _z, surface, self.currents[surface.monitor.name]
583+
x=_x, y=_y, z=_z, surface=surface, currents=currents
538584
)
539585
for field, _field in zip(fields, _fields):
540586
field[0, i, j, :] += _field
@@ -596,16 +642,25 @@ def _project_fields_cartesian(
596642

597643
for surface in self.surfaces:
598644

645+
# apply windowing to currents
646+
currents = self.apply_window_to_currents(
647+
monitor, self.currents[surface.monitor.name]
648+
)
649+
599650
if monitor.far_field_approx:
600651
for idx_f, frequency in enumerate(freqs):
601652
_fields = self._far_fields_for_surface(
602-
frequency, theta, phi, surface, self.currents[surface.monitor.name]
653+
frequency=frequency,
654+
theta=theta,
655+
phi=phi,
656+
surface=surface,
657+
currents=currents,
603658
)
604659
for field, _field in zip(fields, _fields):
605660
field[i, j, k, idx_f] += _field * phase[idx_f]
606661
else:
607662
_fields = self._fields_for_surface_exact(
608-
_x, _y, _z, surface, self.currents[surface.monitor.name]
663+
x=_x, y=_y, z=_z, surface=surface, currents=currents
609664
)
610665
for field, _field in zip(fields, _fields):
611666
field[i, j, k, :] += _field
@@ -658,18 +713,27 @@ def _project_fields_kspace(
658713

659714
for surface in self.surfaces:
660715

716+
# apply windowing to currents
717+
currents = self.apply_window_to_currents(
718+
monitor, self.currents[surface.monitor.name]
719+
)
720+
661721
if monitor.far_field_approx:
662722
for idx_f, frequency in enumerate(freqs):
663723
_fields = self._far_fields_for_surface(
664-
frequency, theta, phi, surface, self.currents[surface.monitor.name]
724+
frequency=frequency,
725+
theta=theta,
726+
phi=phi,
727+
surface=surface,
728+
currents=currents,
665729
)
666730
for field, _field in zip(fields, _fields):
667731
field[i, j, 0, idx_f] += _field * phase[idx_f]
668732

669733
else:
670734
_x, _y, _z = monitor.sph_2_car(monitor.proj_distance, theta, phi)
671735
_fields = self._fields_for_surface_exact(
672-
_x, _y, _z, surface, self.currents[surface.monitor.name]
736+
x=_x, y=_y, z=_z, surface=surface, currents=currents
673737
)
674738
for field, _field in zip(fields, _fields):
675739
field[i, j, 0, :] += _field

tidy3d/components/monitor.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pydantic.v1 as pydantic
66
import numpy as np
77

8-
from .types import Ax, EMField, ArrayFloat1D, FreqArray, FreqBound
8+
from .types import Ax, EMField, ArrayFloat1D, FreqArray, FreqBound, Bound, Size
99
from .types import Literal, Direction, Coordinate, Axis, ObsGridArray, BoxSurface
1010
from .validators import assert_plane
1111
from .base import cached_property, Tidy3dBaseModel
@@ -24,6 +24,12 @@
2424
WARN_NUM_FREQS = 2000
2525
WARN_NUM_MODES = 100
2626

27+
# Field projection windowing factor that determines field decay at the edges of surface field
28+
# projection monitors. A value of 15 leads to a decay of < 1e-3x in field amplitude.
29+
# This number relates directly to the standard deviation of the Gaussian function which is used
30+
# for windowing the monitor.
31+
WINDOW_FACTOR = 15
32+
2733

2834
class Monitor(AbstractMonitor):
2935
"""Abstract base class for monitors."""
@@ -666,6 +672,48 @@ class AbstractFieldProjectionMonitor(SurfaceIntegrationMonitor, FreqMonitor):
666672
"projecting the recorded near fields to the far field.",
667673
)
668674

675+
window_size: Tuple[pydantic.NonNegativeFloat, pydantic.NonNegativeFloat] = pydantic.Field(
676+
(0, 0),
677+
title="Spatial filtering window size",
678+
description="Size of the transition region of the windowing function used to ensure that "
679+
"the recorded near fields decay to zero near the edges of the monitor. "
680+
"The two components refer to the two tangential directions associated with each surface. "
681+
"For surfaces with the normal along ``x``, the two components are (``y``, ``z``). "
682+
"For surfaces with the normal along ``y``, the two components are (``x``, ``z``). "
683+
"For surfaces with the normal along ``z``, the two components are (``x``, ``y``). "
684+
"Each value must be between 0 and 1, inclusive, and denotes the size of the transition "
685+
"region over which fields are scaled to less than a thousandth of the original amplitude, "
686+
"relative to half the size of the monitor in that direction. A value of 0 turns windowing "
687+
"off in that direction, while a value of 1 indicates that the window will be applied to "
688+
"the entire monitor in that direction. This field is applicable for surface monitors only, "
689+
"and otherwise must remain (0, 0).",
690+
)
691+
692+
@pydantic.validator("window_size", always=True)
693+
def window_size_for_surface(cls, val, values):
694+
"""Ensures that windowing is applied for surface monitors only."""
695+
size = values.get("size")
696+
name = values.get("name")
697+
698+
if size.count(0.0) != 1:
699+
if val != (0, 0):
700+
raise ValidationError(
701+
f"A non-zero 'window_size' cannot be used for projection monitor '{name}'. "
702+
"Windowing can be applied only for surface projection monitors."
703+
)
704+
return val
705+
706+
@pydantic.validator("window_size", always=True)
707+
def window_size_leq_one(cls, val, values):
708+
"""Ensures that each component of the window size is less than or equal to 1."""
709+
name = values.get("name")
710+
if val[0] > 1 or val[1] > 1:
711+
raise ValidationError(
712+
f"Each component of 'window_size' for monitor '{name}' "
713+
"must be less than or equal to 1."
714+
)
715+
return val
716+
669717
@property
670718
def projection_surfaces(self) -> Tuple[FieldProjectionSurface, ...]:
671719
"""Surfaces of the monitor where near fields will be recorded for subsequent projection."""
@@ -691,6 +739,62 @@ def local_origin(self) -> Coordinate:
691739
return self.center
692740
return self.custom_origin
693741

742+
def window_parameters(self, custom_bounds: Bound = None) -> Tuple[Size, Coordinate, Coordinate]:
743+
"""Return the physical size of the window transition region based on the monitor's size
744+
and optional custom bounds (useful in case the monitor has infinite dimensions). The window
745+
size is returned in 3D. Also returns the coordinate where the transition region beings on
746+
the minus and plus side of the monitor."""
747+
748+
window_size = [0, 0, 0]
749+
window_minus = [0, 0, 0]
750+
window_plus = [0, 0, 0]
751+
752+
# windowing is for surface monitors only
753+
if self.size.count(0.0) != 1:
754+
return window_size, window_minus, window_plus
755+
756+
_, plane_inds = self.pop_axis([0, 1, 2], axis=self.size.index(0.0))
757+
758+
for i, ind in enumerate(plane_inds):
759+
if custom_bounds:
760+
size = min(self.size[ind], custom_bounds[1][ind] - custom_bounds[0][ind])
761+
bound_min = max(self.bounds[0][ind], custom_bounds[0][ind])
762+
bound_max = min(self.bounds[1][ind], custom_bounds[1][ind])
763+
else:
764+
size = self.size[ind]
765+
bound_min = self.bounds[0][ind]
766+
bound_max = self.bounds[1][ind]
767+
768+
window_size[ind] = self.window_size[i] * size / 2
769+
window_minus[ind] = bound_min + window_size[ind]
770+
window_plus[ind] = bound_max - window_size[ind]
771+
772+
return window_size, window_minus, window_plus
773+
774+
@staticmethod
775+
def window_function(
776+
points: ArrayFloat1D,
777+
window_size: Size,
778+
window_minus: Coordinate,
779+
window_plus: Coordinate,
780+
dim: int,
781+
) -> ArrayFloat1D:
782+
"""Get the windowing function along a given direction for a given set of points."""
783+
rising_window = np.exp(
784+
-0.5
785+
* WINDOW_FACTOR
786+
* ((points[points < window_minus[dim]] - window_minus[dim]) / window_size[dim]) ** 2
787+
)
788+
falling_window = np.exp(
789+
-0.5
790+
* WINDOW_FACTOR
791+
* ((points[points > window_plus[dim]] - window_plus[dim]) / window_size[dim]) ** 2
792+
)
793+
window_fn = np.ones_like(points)
794+
window_fn[points < window_minus[dim]] = rising_window
795+
window_fn[points > window_plus[dim]] = falling_window
796+
return window_fn
797+
694798

695799
class FieldProjectionAngleMonitor(AbstractFieldProjectionMonitor):
696800
""":class:`Monitor` that samples electromagnetic near fields in the frequency domain

0 commit comments

Comments
 (0)