From f4654967bc99f315929cca2de09520648994cad3 Mon Sep 17 00:00:00 2001
From: Qiming <qiming@flexcompute.com>
Date: Fri, 13 Sep 2024 17:36:45 -0400
Subject: [PATCH] Error if projection monitor touches periodic/bloch boundaries
 in 3D simulations

---
 CHANGELOG.md                             |  3 ++
 tests/test_components/test_simulation.py | 38 ++++++++++++++++++++--
 tests/utils.py                           | 16 ++++-----
 tidy3d/components/simulation.py          | 41 ++++++++++++++++++++++++
 4 files changed, 87 insertions(+), 11 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index bce65683f..5bbed77ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+- Validator `boundaries_for_proj_mnts` to error when projection monitor touches periodic/bloch boundaries in 3D simulations.
+
 ## [2.7.3] - 2024-09-12
 
 ### Added
diff --git a/tests/test_components/test_simulation.py b/tests/test_components/test_simulation.py
index b68dea733..318954af1 100644
--- a/tests/test_components/test_simulation.py
+++ b/tests/test_components/test_simulation.py
@@ -1012,7 +1012,7 @@ def test_proj_monitor_distance(log_capture):
 
     monitor_n2f = td.FieldProjectionAngleMonitor(
         center=(0, 0, 0),
-        size=(td.inf, td.inf, 0),
+        size=(0.99, 0.99, 0),
         freqs=[250e12, 300e12],
         name="monitor_n2f",
         theta=[0],
@@ -1023,7 +1023,7 @@ def test_proj_monitor_distance(log_capture):
 
     monitor_n2f_far = td.FieldProjectionAngleMonitor(
         center=(0, 0, 0),
-        size=(td.inf, td.inf, 0),
+        size=(0.99, 0.99, 0),
         freqs=[250e12, 300e12],
         name="monitor_n2f",
         theta=[0],
@@ -1034,7 +1034,7 @@ def test_proj_monitor_distance(log_capture):
 
     monitor_n2f_approx = td.FieldProjectionAngleMonitor(
         center=(0, 0, 0),
-        size=(td.inf, td.inf, 0),
+        size=(0.99, 0.99, 0),
         freqs=[250e12, 300e12],
         name="monitor_n2f",
         theta=[0],
@@ -1783,6 +1783,38 @@ def test_tfsf_structures_grid(log_capture):
         sim.validate_pre_upload()
 
 
+def test_error_bloch_proj_mnts():
+    """Test if Bloch/periodic boundaries touching projection monitors raise an error in 3D simulations."""
+
+    monitor_n2f = td.FieldProjectionAngleMonitor(
+        center=(0, 0, 0),
+        size=(2, 2, 0),
+        freqs=[2.5e14],
+        name="monitor_n2f",
+        theta=[0],
+        phi=[0],
+        proj_distance=1e5,
+    )
+    with pytest.raises(pydantic.ValidationError):
+        _ = td.Simulation(
+            size=(2, 2, 2),
+            structures=[],
+            sources=[
+                td.PointDipole(
+                    center=(0, 0, 0),
+                    polarization="Ex",
+                    source_time=td.GaussianPulse(
+                        freq0=1e14,
+                        fwidth=1e12,
+                    ),
+                )
+            ],
+            run_time=1e-12,
+            boundary_spec=td.BoundarySpec.all_sides(boundary=td.Periodic()),
+            monitors=[monitor_n2f],
+        )
+
+
 @pytest.mark.parametrize(
     "size, num_struct, log_level", [(1, 1, None), (50, 1, "WARNING"), (1, 11000, "WARNING")]
 )
diff --git a/tests/utils.py b/tests/utils.py
index 16f45f6a6..aba3aaafa 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -790,8 +790,8 @@ def make_custom_data(lims, unstructured):
             mode_spec=td.ModeSpec(),
         ),
         td.FieldProjectionAngleMonitor(
-            center=(0, 0, 0),
-            size=(0, 2, 2),
+            center=(0.1, 0.05, 0.02),
+            size=(0, 0.49, 0.69),
             freqs=[250e12, 300e12],
             name="proj_angle",
             custom_origin=(1, 2, 3),
@@ -799,8 +799,8 @@ def make_custom_data(lims, unstructured):
             theta=np.linspace(np.pi / 4, np.pi / 4 + np.pi / 2, 100),
         ),
         td.FieldProjectionCartesianMonitor(
-            center=(0, 0, 0),
-            size=(0, 2, 2),
+            center=(0.1, 0.05, 0.02),
+            size=(0, 0.49, 0.69),
             freqs=[250e12, 300e12],
             name="proj_cartesian",
             custom_origin=(1, 2, 3),
@@ -810,8 +810,8 @@ def make_custom_data(lims, unstructured):
             proj_distance=5,
         ),
         td.FieldProjectionKSpaceMonitor(
-            center=(0, 0, 0),
-            size=(0, 2, 2),
+            center=(0.1, 0.05, 0.02),
+            size=(0, 0.49, 0.69),
             freqs=[250e12, 300e12],
             name="proj_kspace",
             custom_origin=(1, 2, 3),
@@ -820,8 +820,8 @@ def make_custom_data(lims, unstructured):
             uy=[0.03, 0.04, 0.05],
         ),
         td.FieldProjectionAngleMonitor(
-            center=(0, 0, 0),
-            size=(0, 2, 2),
+            center=(0.1, 0.05, 0.02),
+            size=(0, 0.49, 0.69),
             freqs=[250e12, 300e12],
             name="proj_angle_exact",
             custom_origin=(1, 2, 3),
diff --git a/tidy3d/components/simulation.py b/tidy3d/components/simulation.py
index b64c3f26e..db11e2e8a 100644
--- a/tidy3d/components/simulation.py
+++ b/tidy3d/components/simulation.py
@@ -2494,6 +2494,47 @@ def boundaries_for_zero_dims(cls, val, values):
 
         return val
 
+    @pydantic.validator("monitors", always=True)
+    @skip_if_fields_missing(["size", "boundary_spec"])
+    def boundaries_for_proj_mnts(cls, val, values):
+        """Error if periodic boundaries or bloch boundaries touch field projection monitors in 3D simulations."""
+
+        if val is None:
+            return val
+
+        sim_size = values.get("size")
+        sim_center = values.get("center")
+        boundaries = values.get("boundary_spec").to_list
+
+        # Simulation domain as a Box
+        simulation_box = Box(size=sim_size, center=sim_center)
+
+        axis_names = "xyz"
+
+        for dim, boundary in enumerate(boundaries):
+            axis = axis_names[dim]
+            num_periodic_bloch_boundaries = sum(
+                isinstance(bnd, (Periodic, BlochBoundary)) for bnd in boundary
+            )
+
+            if num_periodic_bloch_boundaries > 0:
+                for monitor in val:
+                    if (
+                        isinstance(monitor, AbstractFieldProjectionMonitor)
+                        and sim_size.count(0.0) == 0
+                    ):
+                        if (
+                            monitor.bounds[0][dim] <= simulation_box.bounds[0][dim]
+                            or monitor.bounds[1][dim] >= simulation_box.bounds[1][dim]
+                        ):
+                            raise SetupError(
+                                f"The '{monitor.name}' monitor touches periodic/bloch boundaries "
+                                f"along the '{axis}' axis. This would lead to incorrect results. "
+                                f"Please adjust the monitor size to fit within the simulation domain."
+                            )
+
+        return val
+
     @pydantic.validator("sources", always=True)
     def _validate_num_sources(cls, val):
         """Error if too many sources present."""