diff --git a/CHANGELOG.md b/CHANGELOG.md index 616a192850..8e459eb916 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/tests/test_components/autograd/test_adjoint_monitors.py b/tests/test_components/autograd/test_adjoint_monitors.py new file mode 100644 index 0000000000..7994d9d47e --- /dev/null +++ b/tests/test_components/autograd/test_adjoint_monitors.py @@ -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)) diff --git a/tests/utils.py b/tests/utils.py index adf28676ad..7c12806632 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -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) data = DATA_GEN_FN(data_shape) data = (1 + 0.5j) * data if is_complex else data diff --git a/tidy3d/components/simulation.py b/tidy3d/components/simulation.py index 17687d751a..325b85eebb 100644 --- a/tidy3d/components/simulation.py +++ b/tidy3d/components/simulation.py @@ -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 adjoint_monitors_fld = [] adjoint_monitors_eps = [] @@ -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) diff --git a/tidy3d/components/structure.py b/tidy3d/components/structure.py index 6bd11d8e9e..bd90e6417b 100644 --- a/tidy3d/components/structure.py +++ b/tidy3d/components/structure.py @@ -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]