Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed bug where an extra spatial coordinate could appear in `complex_flux` and `ImpedanceCalculator` results.
- Fixed normal for `Box` shape gradient computation to always point outward from boundary which is needed for correct PEC handling.
- Fixed `Box` gradients within `GeometryGroup` where the group intersection boundaries were forwarded.
- Cropped adjoint monitor sizes in 2D simulations to planar geometry intersection.

## [2.10.0rc3] - 2025-11-26

Expand Down
185 changes: 185 additions & 0 deletions tests/test_components/autograd/test_adjoint_monitors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Tests for adjoint monitor sizing on planar simulations."""

from __future__ import annotations

import numpy as np
import pytest

import tidy3d as td

SIM_FIELDS_KEYS = [("dummy", 0, "geometry")]

POLY_VERTS_2D: np.ndarray = np.array(
[
(0.0, 0.0),
(3.0, 0.0),
(4.0, 2.0),
(2.0, 4.0),
(0.0, 3.0),
],
dtype=float,
)


def _make_2d_simulation(structure: td.Structure) -> td.Simulation:
return td.Simulation(
size=(4.0, 4.0, 0.0),
run_time=1e-12,
grid_spec=td.GridSpec.uniform(dl=0.2),
boundary_spec=td.BoundarySpec.pml(x=True, y=True, z=False),
structures=[structure],
sources=[],
monitors=[
td.FieldMonitor(center=(0, 0, 0), size=(0, 0, 0), freqs=[2e14], name="ref"),
],
)


def _make_tetra_mesh() -> td.TriangleMesh:
# Reuse the same tetra mesh everywhere (matches the earlier 2D test geometry).
vertices = np.array(
[
(1.0, 0.0, -0.1),
(-1.0, 0.0, 0.1),
(0.0, 1.0, 0.1),
(0.0, -1.0, 0.1),
],
dtype=float,
)
faces = np.array(
[
(0, 1, 2),
(0, 1, 3),
(0, 2, 3),
(1, 2, 3),
],
dtype=int,
)
return td.TriangleMesh.from_vertices_faces(vertices, faces)


@pytest.mark.parametrize(
"center_z, expected_size",
[
(0.0, (1.0, 1.0, 0.0)),
(0.25, (2 * np.sqrt(0.5**2 - 0.25**2),) * 2 + (0.0,)),
],
)
def test_adjoint_monitors_use_plane_bounds_sphere(center_z, expected_size):
structure = td.Structure(
geometry=td.Sphere(radius=0.5, center=(0, 0, center_z)), medium=td.Medium()
)
sim = _make_2d_simulation(structure)

monitors_field, monitors_eps = sim._make_adjoint_monitors(SIM_FIELDS_KEYS)

assert monitors_field[0].size == pytest.approx(expected_size)
assert monitors_field[0].center == pytest.approx((0.0, 0.0, 0.0))
assert monitors_eps[0].size == pytest.approx(expected_size)


def test_adjoint_monitors_use_plane_bounds_mesh():
mesh = _make_tetra_mesh()
structure = td.Structure(geometry=mesh, medium=td.Medium())
sim = _make_2d_simulation(structure)

monitors_field, monitors_eps = sim._make_adjoint_monitors(SIM_FIELDS_KEYS)

assert monitors_field[0].size == pytest.approx((0.5, 1.0, 0.0))
assert monitors_field[0].center == pytest.approx((0.25, 0.0, 0.0))
assert monitors_eps[0].size == pytest.approx((0.5, 1.0, 0.0))


def test_adjoint_monitors_use_plane_bounds_mesh_disjoint_components():
"""
Disjoint mesh components: adjoint-plane monitor should use the union of
all intersection bounds (not just one component).
"""

# Two identical tetrahedra, separated in x, symmetric about the origin.
# Each component spans:
# x: [-2, -1] and [1, 2]
# y: [-1, 1] for both
vertices = np.array(
[
# Left component (x in [-2, -1])
(-2.0, 0.0, 1.0), # 0
(-1.0, 0.0, -1.0), # 1
(-2.0, 1.0, -1.0), # 2
(-2.0, -1.0, -1.0), # 3
# Right component (x in [1, 2])
(2.0, 0.0, 1.0), # 4
(1.0, 0.0, -1.0), # 5
(2.0, 1.0, -1.0), # 6
(2.0, -1.0, -1.0), # 7
],
dtype=float,
)

# Faces for each tetrahedron (same connectivity, offset by +4 for right)
faces = np.array(
[
(0, 1, 2),
(0, 1, 3),
(0, 2, 3),
(1, 2, 3),
(4, 5, 6),
(4, 5, 7),
(4, 6, 7),
(5, 6, 7),
],
dtype=int,
)

mesh = td.TriangleMesh.from_vertices_faces(vertices, faces)
structure = td.Structure(geometry=mesh, medium=td.Medium())
sim = _make_2d_simulation(structure)

monitors_field, monitors_eps = sim._make_adjoint_monitors(SIM_FIELDS_KEYS)

# Union across both components:
# x spans [-2, 2] -> size 4
# y spans [-0.5, 0.5] -> size 1 (note we are interested in z=0 plane, mid y between +-1 and 0)
# z size is 0 for a 2D plane monitor
assert monitors_field[0].size == pytest.approx((4.0, 1.0, 0.0))
assert monitors_field[0].center == pytest.approx((0.0, 0.0, 0.0))
assert monitors_eps[0].size == pytest.approx((4.0, 1.0, 0.0))


def _make_3d_simulation(structure: td.Structure) -> td.Simulation:
return td.Simulation(
size=(4.0, 4.0, 4.0),
run_time=1e-12,
grid_spec=td.GridSpec.uniform(dl=0.2),
boundary_spec=td.BoundarySpec.pml(x=True, y=True, z=True),
structures=[structure],
sources=[],
monitors=[
td.FieldMonitor(center=(0, 0, 0), size=(0, 0, 0), freqs=[2e14], name="ref"),
],
)


@pytest.mark.parametrize(
"geometry",
[
td.Sphere(radius=0.5, center=(0.3, -0.2, 0.1)),
td.Cylinder(radius=0.3, length=0.8, center=(-0.5, 0.4, -0.1), axis=2),
td.Box(center=(0.2, 0.1, -0.3), size=(0.6, 0.8, 0.4)),
_make_tetra_mesh(),
td.PolySlab(vertices=POLY_VERTS_2D, axis=2, slab_bounds=(-1, 1)),
],
ids=["sphere", "cylinder", "box", "mesh", "polyslab"],
)
def test_adjoint_monitors_3d_use_geometry_bounding_box(geometry):
structure = td.Structure(geometry=geometry, medium=td.Medium())
sim = _make_3d_simulation(structure)

monitors_field, monitors_eps = sim._make_adjoint_monitors(SIM_FIELDS_KEYS)

expected_box = geometry.bounding_box

assert monitors_field[0].size == pytest.approx(tuple(expected_box.size))
assert monitors_field[0].center == pytest.approx(tuple(expected_box.center))
assert monitors_eps[0].size == pytest.approx(tuple(expected_box.size))
assert monitors_eps[0].center == pytest.approx(tuple(expected_box.center))
2 changes: 1 addition & 1 deletion tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1156,7 +1156,7 @@ def make_data(
) -> td.components.data.data_array.DataArray:
"""make a random DataArray out of supplied coordinates and data_type."""
data_shape = [len(coords[k]) for k in data_array_type._dims]
np.random.seed(1)
np.random.seed(0)
Copy link
Contributor Author

@marcorudolphflex marcorudolphflex Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's a funny one. It caused a gradient being 2e-10 by chance in test_multi_frequency_equivalence

data = DATA_GEN_FN(data_shape)

data = (1 + 0.5j) * data if is_complex else data
Expand Down
3 changes: 2 additions & 1 deletion tidy3d/components/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4884,6 +4884,7 @@ def _make_adjoint_monitors(self, sim_fields_keys: list) -> tuple[list, list]:
index_to_keys[index].append(fields)

freqs = self._freqs_adjoint
sim_plane = self if self.size.count(0.0) == 1 else None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we have adjoint monitors that are 3D, but the simulation is 2D, I believe the clipping will happen naturally in that the adjoint monitor will just have a 2D overlap with the simulation and so we will only get 2D data. I might be missing something though!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah it does and this is fine most of the times. I target cases like here where the geometry bounds do not reflect the bounds within the sim plane: The non-centered Sphere has smaller bounds in the simulation plane. Same would apply for non-prism-like meshes.
Basically it is more about performance/efficiency as we may only shrink the monitor potentially.

image

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that makes sense!


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

mnt_fld, mnt_eps = structure._make_adjoint_monitors(
freqs=freqs, index=i, field_keys=field_keys
freqs=freqs, index=i, field_keys=field_keys, plane=sim_plane
)

adjoint_monitors_fld.append(mnt_fld)
Expand Down
41 changes: 38 additions & 3 deletions tidy3d/components/structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,47 @@ def _get_monitor_name(index: int, data_type: str) -> str:
return monitor_name_map[data_type]

def _make_adjoint_monitors(
self, freqs: list[float], index: int, field_keys: list[str]
) -> (FieldMonitor, PermittivityMonitor):
self,
freqs: list[float],
index: int,
field_keys: list[str],
plane: Optional[Box] = None,
) -> tuple[FieldMonitor, PermittivityMonitor]:
"""Generate the field and permittivity monitor for this structure."""

geometry = self.geometry
box = geometry.bounding_box
geom_box = geometry.bounding_box

def _box_from_plane_intersection() -> Box:
plane_axis = plane._normal_axis
plane_position = plane.center[plane_axis]
axis_char = "xyz"[plane_axis]

intersections = geometry.intersections_plane(**{axis_char: plane_position})
bounds = [shape.bounds for shape in intersections if not shape.is_empty]
if len(bounds) == 0:
intersections = geom_box.intersections_plane(**{axis_char: plane_position})
bounds = [shape.bounds for shape in intersections if not shape.is_empty]
if len(bounds) == 0: # fallback
return geom_box

min_plane = (min(b[0] for b in bounds), min(b[1] for b in bounds))
max_plane = (max(b[2] for b in bounds), max(b[3] for b in bounds))

rmin = [plane_position, plane_position, plane_position]
rmax = [plane_position, plane_position, plane_position]

_, plane_axes = Geometry.pop_axis((0, 1, 2), axis=plane_axis)
for ind, ax in enumerate(plane_axes):
rmin[ax] = min_plane[ind]
rmax[ax] = max_plane[ind]

return Box.from_bounds(tuple(rmin), tuple(rmax))

if plane is not None:
box = _box_from_plane_intersection()
else:
box = geom_box

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