Skip to content

Commit f6e3d0f

Browse files
fix(tidy3d): FXC-4563-use-2-d-intersections-for-adjoint-monitor-sizes-in-2-d-simulations
1 parent ccc0f42 commit f6e3d0f

File tree

5 files changed

+227
-5
lines changed

5 files changed

+227
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Fixed interpolation handling for permittivity and conductivity gradients in CustomMedium.
2222
- Restored original batch-load logging by suppressing per-task “Loading simulation…” messages.
2323
- Fixed output range of `tidy3d.plugins.invdes.FilterAndProject` to be between 0 and 1.
24+
- Cropped adjoint monitor sizes in 2D simulations to planar geometry intersection.
2425

2526
## [2.10.0] - 2025-12-18
2627

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Tests for adjoint monitor sizing on planar simulations."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
import pytest
7+
8+
import tidy3d as td
9+
10+
SIM_FIELDS_KEYS = [("dummy", 0, "geometry")]
11+
12+
POLY_VERTS_2D: np.ndarray = np.array(
13+
[
14+
(0.0, 0.0),
15+
(3.0, 0.0),
16+
(4.0, 2.0),
17+
(2.0, 4.0),
18+
(0.0, 3.0),
19+
],
20+
dtype=float,
21+
)
22+
23+
24+
def _make_2d_simulation(structure: td.Structure) -> td.Simulation:
25+
return td.Simulation(
26+
size=(4.0, 4.0, 0.0),
27+
run_time=1e-12,
28+
grid_spec=td.GridSpec.uniform(dl=0.2),
29+
boundary_spec=td.BoundarySpec.pml(x=True, y=True, z=False),
30+
structures=[structure],
31+
sources=[],
32+
monitors=[
33+
td.FieldMonitor(center=(0, 0, 0), size=(0, 0, 0), freqs=[2e14], name="ref"),
34+
],
35+
)
36+
37+
38+
def _make_tetra_mesh() -> td.TriangleMesh:
39+
# Reuse the same tetra mesh everywhere (matches the earlier 2D test geometry).
40+
vertices = np.array(
41+
[
42+
(1.0, 0.0, -0.1),
43+
(-1.0, 0.0, 0.1),
44+
(0.0, 1.0, 0.1),
45+
(0.0, -1.0, 0.1),
46+
],
47+
dtype=float,
48+
)
49+
faces = np.array(
50+
[
51+
(0, 1, 2),
52+
(0, 1, 3),
53+
(0, 2, 3),
54+
(1, 2, 3),
55+
],
56+
dtype=int,
57+
)
58+
return td.TriangleMesh.from_vertices_faces(vertices, faces)
59+
60+
61+
@pytest.mark.parametrize(
62+
"center_z, expected_size",
63+
[
64+
(0.0, (1.0, 1.0, 0.0)),
65+
(0.25, (2 * np.sqrt(0.5**2 - 0.25**2),) * 2 + (0.0,)),
66+
],
67+
)
68+
def test_adjoint_monitors_use_plane_bounds_sphere(center_z, expected_size):
69+
structure = td.Structure(
70+
geometry=td.Sphere(radius=0.5, center=(0, 0, center_z)), medium=td.Medium()
71+
)
72+
sim = _make_2d_simulation(structure)
73+
74+
monitors_field, monitors_eps = sim._make_adjoint_monitors(SIM_FIELDS_KEYS)
75+
76+
assert monitors_field[0].size == pytest.approx(expected_size)
77+
assert monitors_field[0].center == pytest.approx((0.0, 0.0, 0.0))
78+
assert monitors_eps[0].size == pytest.approx(expected_size)
79+
80+
81+
def test_adjoint_monitors_use_plane_bounds_mesh():
82+
mesh = _make_tetra_mesh()
83+
structure = td.Structure(geometry=mesh, medium=td.Medium())
84+
sim = _make_2d_simulation(structure)
85+
86+
monitors_field, monitors_eps = sim._make_adjoint_monitors(SIM_FIELDS_KEYS)
87+
88+
assert monitors_field[0].size == pytest.approx((0.5, 1.0, 0.0))
89+
assert monitors_field[0].center == pytest.approx((0.25, 0.0, 0.0))
90+
assert monitors_eps[0].size == pytest.approx((0.5, 1.0, 0.0))
91+
92+
93+
def test_adjoint_monitors_use_plane_bounds_mesh_disjoint_components():
94+
"""
95+
Disjoint mesh components: adjoint-plane monitor should use the union of
96+
all intersection bounds (not just one component).
97+
"""
98+
99+
# Two identical tetrahedra, separated in x, symmetric about the origin.
100+
# Each component spans:
101+
# x: [-2, -1] and [1, 2]
102+
# y: [-1, 1] for both
103+
vertices = np.array(
104+
[
105+
# Left component (x in [-2, -1])
106+
(-2.0, 0.0, 1.0), # 0
107+
(-1.0, 0.0, -1.0), # 1
108+
(-2.0, 1.0, -1.0), # 2
109+
(-2.0, -1.0, -1.0), # 3
110+
# Right component (x in [1, 2])
111+
(2.0, 0.0, 1.0), # 4
112+
(1.0, 0.0, -1.0), # 5
113+
(2.0, 1.0, -1.0), # 6
114+
(2.0, -1.0, -1.0), # 7
115+
],
116+
dtype=float,
117+
)
118+
119+
# Faces for each tetrahedron (same connectivity, offset by +4 for right)
120+
faces = np.array(
121+
[
122+
(0, 1, 2),
123+
(0, 1, 3),
124+
(0, 2, 3),
125+
(1, 2, 3),
126+
(4, 5, 6),
127+
(4, 5, 7),
128+
(4, 6, 7),
129+
(5, 6, 7),
130+
],
131+
dtype=int,
132+
)
133+
134+
mesh = td.TriangleMesh.from_vertices_faces(vertices, faces)
135+
structure = td.Structure(geometry=mesh, medium=td.Medium())
136+
sim = _make_2d_simulation(structure)
137+
138+
monitors_field, monitors_eps = sim._make_adjoint_monitors(SIM_FIELDS_KEYS)
139+
140+
# Union across both components:
141+
# x spans [-2, 2] -> size 4
142+
# y spans [-0.5, 0.5] -> size 1 (note we are interested in z=0 plane, mid y between +-1 and 0)
143+
# z size is 0 for a 2D plane monitor
144+
assert monitors_field[0].size == pytest.approx((4.0, 1.0, 0.0))
145+
assert monitors_field[0].center == pytest.approx((0.0, 0.0, 0.0))
146+
assert monitors_eps[0].size == pytest.approx((4.0, 1.0, 0.0))
147+
148+
149+
def _make_3d_simulation(structure: td.Structure) -> td.Simulation:
150+
return td.Simulation(
151+
size=(4.0, 4.0, 4.0),
152+
run_time=1e-12,
153+
grid_spec=td.GridSpec.uniform(dl=0.2),
154+
boundary_spec=td.BoundarySpec.pml(x=True, y=True, z=True),
155+
structures=[structure],
156+
sources=[],
157+
monitors=[
158+
td.FieldMonitor(center=(0, 0, 0), size=(0, 0, 0), freqs=[2e14], name="ref"),
159+
],
160+
)
161+
162+
163+
@pytest.mark.parametrize(
164+
"geometry",
165+
[
166+
td.Sphere(radius=0.5, center=(0.3, -0.2, 0.1)),
167+
td.Cylinder(radius=0.3, length=0.8, center=(-0.5, 0.4, -0.1), axis=2),
168+
td.Box(center=(0.2, 0.1, -0.3), size=(0.6, 0.8, 0.4)),
169+
_make_tetra_mesh(),
170+
td.PolySlab(vertices=POLY_VERTS_2D, axis=2, slab_bounds=(-1, 1)),
171+
],
172+
ids=["sphere", "cylinder", "box", "mesh", "polyslab"],
173+
)
174+
def test_adjoint_monitors_3d_use_geometry_bounding_box(geometry):
175+
structure = td.Structure(geometry=geometry, medium=td.Medium())
176+
sim = _make_3d_simulation(structure)
177+
178+
monitors_field, monitors_eps = sim._make_adjoint_monitors(SIM_FIELDS_KEYS)
179+
180+
expected_box = geometry.bounding_box
181+
182+
assert monitors_field[0].size == pytest.approx(tuple(expected_box.size))
183+
assert monitors_field[0].center == pytest.approx(tuple(expected_box.center))
184+
assert monitors_eps[0].size == pytest.approx(tuple(expected_box.size))
185+
assert monitors_eps[0].center == pytest.approx(tuple(expected_box.center))

tests/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1156,7 +1156,7 @@ def make_data(
11561156
) -> td.components.data.data_array.DataArray:
11571157
"""make a random DataArray out of supplied coordinates and data_type."""
11581158
data_shape = [len(coords[k]) for k in data_array_type._dims]
1159-
np.random.seed(1)
1159+
np.random.seed(0)
11601160
data = DATA_GEN_FN(data_shape)
11611161

11621162
data = (1 + 0.5j) * data if is_complex else data

tidy3d/components/simulation.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4884,6 +4884,7 @@ def _make_adjoint_monitors(self, sim_fields_keys: list) -> tuple[list, list]:
48844884
index_to_keys[index].append(fields)
48854885

48864886
freqs = self._freqs_adjoint
4887+
sim_plane = self if self.size.count(0.0) == 1 else None
48874888

48884889
adjoint_monitors_fld = []
48894890
adjoint_monitors_eps = []
@@ -4893,7 +4894,7 @@ def _make_adjoint_monitors(self, sim_fields_keys: list) -> tuple[list, list]:
48934894
structure = self.structures[i]
48944895

48954896
mnt_fld, mnt_eps = structure._make_adjoint_monitors(
4896-
freqs=freqs, index=i, field_keys=field_keys
4897+
freqs=freqs, index=i, field_keys=field_keys, plane=sim_plane
48974898
)
48984899

48994900
adjoint_monitors_fld.append(mnt_fld)

tidy3d/components/structure.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,12 +314,47 @@ def _get_monitor_name(index: int, data_type: str) -> str:
314314
return monitor_name_map[data_type]
315315

316316
def _make_adjoint_monitors(
317-
self, freqs: list[float], index: int, field_keys: list[str]
318-
) -> (FieldMonitor, PermittivityMonitor):
317+
self,
318+
freqs: list[float],
319+
index: int,
320+
field_keys: list[str],
321+
plane: Optional[Box] = None,
322+
) -> tuple[FieldMonitor, PermittivityMonitor]:
319323
"""Generate the field and permittivity monitor for this structure."""
320324

321325
geometry = self.geometry
322-
box = geometry.bounding_box
326+
geom_box = geometry.bounding_box
327+
328+
def _box_from_plane_intersection() -> Box:
329+
plane_axis = plane._normal_axis
330+
plane_position = plane.center[plane_axis]
331+
axis_char = "xyz"[plane_axis]
332+
333+
intersections = geometry.intersections_plane(**{axis_char: plane_position})
334+
bounds = [shape.bounds for shape in intersections if not shape.is_empty]
335+
if len(bounds) == 0:
336+
intersections = geom_box.intersections_plane(**{axis_char: plane_position})
337+
bounds = [shape.bounds for shape in intersections if not shape.is_empty]
338+
if len(bounds) == 0: # fallback
339+
return geom_box
340+
341+
min_plane = (min(b[0] for b in bounds), min(b[1] for b in bounds))
342+
max_plane = (max(b[2] for b in bounds), max(b[3] for b in bounds))
343+
344+
rmin = [plane_position, plane_position, plane_position]
345+
rmax = [plane_position, plane_position, plane_position]
346+
347+
_, plane_axes = Geometry.pop_axis((0, 1, 2), axis=plane_axis)
348+
for ind, ax in enumerate(plane_axes):
349+
rmin[ax] = min_plane[ind]
350+
rmax[ax] = max_plane[ind]
351+
352+
return Box.from_bounds(tuple(rmin), tuple(rmax))
353+
354+
if plane is not None:
355+
box = _box_from_plane_intersection()
356+
else:
357+
box = geom_box
323358

324359
# we dont want these fields getting traced by autograd, otherwise it messes stuff up
325360
size = [get_static(x) for x in box.size]

0 commit comments

Comments
 (0)