Skip to content

Commit 8be02f6

Browse files
committed
Fix to mesher error in very specific cases
Warn when structures are too small compared to the generated mesh
1 parent 1919e90 commit 8be02f6

File tree

4 files changed

+147
-44
lines changed

4 files changed

+147
-44
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
### Added
99
- `ModeData.dispersion` and `ModeSolverData.dispersion` are calculated together with the group index.
1010
- String matching feature `contains_str` to `assert_log_level` testing utility.
11+
- Warning in automatic grid generation if a structure has a non-zero size along a given direction that is too small compared to a single mesh step.
1112

1213
### Changed
1314
- `jax` and `jaxlib` versions bumped to `0.4.*`.
1415

16+
### Fixed
17+
- Error in automatic grid generation in specific cases with multiple thin structures.
18+
1519
## [2.5.0] - 2023-12-13
1620

1721
### Added

tests/test_components/test_meshgenerate.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55

66
import tidy3d as td
77
from tidy3d.constants import fp_eps
8-
98
from tidy3d.components.grid.mesher import GradedMesher
109

10+
from ..utils import assert_log_level, log_capture
11+
1112
np.random.seed(4)
1213

1314
MESHER = GradedMesher()
@@ -650,6 +651,52 @@ def test_mesher_timeout():
650651
_ = sim.grid
651652

652653

654+
def test_small_structure_size(log_capture):
655+
"""Test that a warning is raised if a structure size is small during the auto meshing"""
656+
box_size = 0.03
657+
medium = td.Medium(permittivity=4)
658+
box = td.Structure(geometry=td.Box(size=(box_size, td.inf, td.inf)), medium=medium)
659+
src = td.UniformCurrentSource(
660+
source_time=td.GaussianPulse(freq0=2e14, fwidth=1e13),
661+
size=(0, 0, 0),
662+
polarization="Ex",
663+
)
664+
sim = td.Simulation(
665+
size=(10, 10, 10),
666+
sources=[src],
667+
structures=[box],
668+
run_time=1e-12,
669+
grid_spec=td.GridSpec.auto(wavelength=1),
670+
)
671+
672+
# Warning raised as structure is too thin
673+
assert_log_level(log_capture, "WARNING")
674+
675+
# Warning not raised if structure is higher index
676+
log_capture.clear()
677+
box2 = box.updated_copy(medium=td.Medium(permittivity=300))
678+
sim2 = sim.updated_copy(structures=[box2])
679+
assert len(log_capture) == 0
680+
681+
# Warning not raised if structure is covered by an override structure
682+
log_capture.clear()
683+
override = td.MeshOverrideStructure(geometry=box.geometry, dl=(box_size, td.inf, td.inf))
684+
sim3 = sim.updated_copy(grid_spec=sim.grid_spec.updated_copy(override_structures=[override]))
685+
assert len(log_capture) == 0
686+
# Also check that the structure boundaries are in the grid
687+
ind_mid_cell = int(sim3.grid.num_cells[0] // 2)
688+
bounds = [-box_size / 2, box_size / 2]
689+
assert np.allclose(bounds, sim3.grid.boundaries.x[ind_mid_cell : ind_mid_cell + 2])
690+
691+
# Test that the error coming from two thin slabs on top of each other is resolved
692+
log_capture.clear()
693+
box3 = td.Structure(
694+
geometry=td.Box(center=(box_size, 0, 0), size=(box_size, td.inf, td.inf)), medium=medium
695+
)
696+
sim4 = sim.updated_copy(structures=[box3, box])
697+
assert_log_level(log_capture, "WARNING")
698+
699+
653700
def test_shapely_strtree_warnings():
654701

655702
with warnings.catch_warnings():

tests/test_components/test_simulation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,7 +769,7 @@ def test_large_grid_size(log_capture, grid_size, log_level):
769769
assert_log_level(log_capture, log_level)
770770

771771

772-
@pytest.mark.parametrize("box_size,log_level", [(0.001, "INFO"), (9.9, "WARNING"), (20, "INFO")])
772+
@pytest.mark.parametrize("box_size,log_level", [(0.1, "INFO"), (9.9, "WARNING"), (20, "INFO")])
773773
def test_sim_structure_gap(log_capture, box_size, log_level):
774774
"""Make sure the gap between a structure and PML is not too small compared to lambda0."""
775775
medium = td.Medium(permittivity=2)

tidy3d/components/grid/mesher.py

Lines changed: 94 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,15 @@
1919
from ..medium import AnisotropicMedium, Medium2D, PECMedium
2020
from ...exceptions import SetupError, ValidationError
2121
from ...constants import C_0, fp_eps
22+
from ...log import log
2223

2324
_ROOTS_TOL = 1e-10
2425

26+
# Shrink min_step a little so that if e.g. a structure has target dl = 0.1 and a width of 0.1,
27+
# a grid point will be added on both sides of the structure. Without this factor, the mesher
28+
# ``is_close`` check will deem that the second point is too close.
29+
MIN_STEP_SCALE = 0.9999
30+
2531

2632
class Mesher(Tidy3dBaseModel, ABC):
2733
"""Abstract class for automatic meshing."""
@@ -122,7 +128,7 @@ def parse_structures(
122128
structures_ordered, wavelength, min_steps_per_wvl, dl_min, axis
123129
)
124130
# Smallest of the maximum steps
125-
min_step = np.amin(structure_steps)
131+
min_step = MIN_STEP_SCALE * np.amin(structure_steps)
126132

127133
# If empty simulation, return
128134
if len(structures) == 1:
@@ -135,40 +141,74 @@ def parse_structures(
135141
tree = self.bounds_2d_tree(struct_bbox)
136142

137143
intervals = {"coords": list(domain_bounds), "structs": [[]]}
138-
# Iterate in reverse order as latter structures override earlier ones. To properly handle
139-
# containment then we need to populate interval coordinates starting from the top.
140-
# If a structure is found to be completely contained, the corresponding ``struct_bbox`` is
141-
# set to ``None``.
142-
for str_ind in range(len(structures_ordered) - 1, -1, -1):
143-
# 3D and 2D bounding box of current structure
144-
bbox = struct_bbox[str_ind]
145-
if bbox is None:
146-
# Structure has been removed because it is completely contained
147-
continue
148-
bbox_2d = shapely_box(bbox[0, 0], bbox[0, 1], bbox[1, 0], bbox[1, 1])
149-
150-
# List of structure indexes that may intersect the current structure in 2D
151-
try:
152-
query_inds = tree.query_items(bbox_2d)
153-
except AttributeError:
154-
query_inds = tree.query(bbox_2d)
155-
156-
# Remove all lower structures that the current structure completely contains
157-
inds_lower = [
158-
ind for ind in query_inds if ind < str_ind and struct_bbox[ind] is not None
159-
]
160-
query_bbox = [struct_bbox[ind] for ind in inds_lower]
161-
bbox_contains_inds = self.contains_3d(bbox, query_bbox)
162-
for ind in bbox_contains_inds:
163-
struct_bbox[inds_lower[ind]] = None
164-
165-
# List of structure bboxes that contain the current structure in 2D
166-
inds_upper = [ind for ind in query_inds if ind > str_ind]
167-
query_bbox = [struct_bbox[ind] for ind in inds_upper if struct_bbox[ind] is not None]
168-
bbox_contained_2d = self.contained_2d(bbox, query_bbox)
169-
170-
# Handle insertion of the current structure bounds in the intervals
171-
intervals = self.insert_bbox(intervals, str_ind, bbox, bbox_contained_2d, min_step)
144+
""" Build the ``intervals`` dictionary. ``intervals["coords"]`` gets populated based on the
145+
bounding boxes of all structures in the list (some filtering is done to exclude points that
146+
will be too close together compared to the absolute lower desired step). At every point, the
147+
``"structs"`` list has length one lower than the ``"coords"`` list, and each element is
148+
another list of all structure indexes that are found in the interval formed by ``coords[i]``
149+
and ``coords[i + 1]``. This only includes structures that have a physical presence in the
150+
interval, i.e. it excludes structures that are completely covered by higher-up ones.
151+
152+
To build this, we iterate in reverse order as latter structures override earlier ones.
153+
We also handle containment in the following way (note - we work with bounding boxes only):
154+
1. If a structure that is lower in the list than the current structure is found to be
155+
completely contained in 3D, the corresponding ``struct_bbox`` is immediately set to
156+
``None`` and nothing more will be done using that structure.
157+
2. If the current structure is covering an interval but there's a higher-up structure that
158+
contains it in 2D and also covers the same interval, then it will not be added to the
159+
``intervals["structs"] list for that interval.
160+
3. If the current structure is found to not cover any interval, its bounding box
161+
is set to ``None``, so that it will not affect structures that lie below it as per point
162+
2. A warning is also raised since the structure will have an unpredictable effect on the
163+
material coefficients used in the simulation.
164+
"""
165+
166+
with log:
167+
for str_ind in range(len(structures_ordered) - 1, -1, -1):
168+
# 3D and 2D bounding box of current structure
169+
bbox = struct_bbox[str_ind]
170+
if bbox is None:
171+
# Structure has been removed because it is completely contained
172+
continue
173+
bbox_2d = shapely_box(bbox[0, 0], bbox[0, 1], bbox[1, 0], bbox[1, 1])
174+
175+
# List of structure indexes that may intersect the current structure in 2D
176+
try:
177+
query_inds = tree.query_items(bbox_2d)
178+
except AttributeError:
179+
query_inds = tree.query(bbox_2d)
180+
181+
# Remove all lower structures that the current structure completely contains
182+
inds_lower = [
183+
ind for ind in query_inds if ind < str_ind and struct_bbox[ind] is not None
184+
]
185+
query_bbox = [struct_bbox[ind] for ind in inds_lower]
186+
bbox_contains_inds = self.contains_3d(bbox, query_bbox)
187+
for ind in bbox_contains_inds:
188+
struct_bbox[inds_lower[ind]] = None
189+
190+
# List of structure bboxes that contain the current structure in 2D
191+
inds_upper = [ind for ind in query_inds if ind > str_ind]
192+
query_bbox = [
193+
struct_bbox[ind] for ind in inds_upper if struct_bbox[ind] is not None
194+
]
195+
bbox_contained_2d = self.contained_2d(bbox, query_bbox)
196+
197+
# Handle insertion of the current structure bounds in the intervals
198+
# The intervals list is modified in-place
199+
too_small = self.insert_bbox(intervals, str_ind, bbox, bbox_contained_2d, min_step)
200+
if too_small and (bbox[1, 2] - bbox[0, 2]) > 0:
201+
# If the structure is too small (but not 0D), issue a warning
202+
log.warning(
203+
f"A structure has a nonzero dimension along axis {'xyz'[axis]}, which "
204+
"is however too small compared to the generated mesh step along that "
205+
"direction. This could produce unpredictable results. We recommend "
206+
"increasing the resolution, or adding a mesh override structure to ensure "
207+
"that all geometries are at least one pixel thick along all dimensions."
208+
)
209+
# Also remove this structure from the bbox list so that lower structures
210+
# will not be affected by it, as it was not added to any interval.
211+
struct_bbox[str_ind] = None
172212

173213
# Truncate intervals to domain bounds
174214
coords = np.array(intervals["coords"])
@@ -226,17 +266,29 @@ def insert_bbox(
226266
List of 3D bounding boxes that contain the current structure in 2D.
227267
min_step : float
228268
Absolute minimum interval size to impose.
269+
270+
Returns
271+
-------
272+
structure_too_small : bool
273+
True if the structure did not span any interval after coordinates were inserted. This
274+
would happen if the structure size is too small compared to the minimum step.
275+
276+
Note
277+
----
278+
This function modifies ``intervals`` in-place.
229279
"""
230280

231281
coords = intervals["coords"]
232282
structs = intervals["structs"]
233283

284+
min_step_check = MIN_STEP_SCALE * min_step
285+
234286
# Left structure bound
235287
bound_coord = str_bbox[0, 2]
236288
indsmin = np.nonzero(bound_coord <= coords)[0]
237-
indmin = int(indsmin[0]) # coordinate is in interval index ``indmin - 1````
238-
is_close_l = self.is_close(bound_coord, coords, indmin - 1, min_step)
239-
is_close_r = self.is_close(bound_coord, coords, indmin, min_step)
289+
indmin = int(indsmin[0]) # coordinate is in interval index ``indmin - 1``
290+
is_close_l = self.is_close(bound_coord, coords, indmin - 1, min_step_check)
291+
is_close_r = self.is_close(bound_coord, coords, indmin, min_step_check)
240292
is_contained = self.is_contained(bound_coord, bbox_contained_2d)
241293

242294
# Decide on whether coordinate should be inserted or indmin modified
@@ -254,8 +306,8 @@ def insert_bbox(
254306
bound_coord = str_bbox[1, 2]
255307
indsmax = np.nonzero(bound_coord >= coords)[0]
256308
indmax = int(indsmax[-1]) # coordinate is in interval index ``indmax``
257-
is_close_l = self.is_close(bound_coord, coords, indmax, min_step)
258-
is_close_r = self.is_close(bound_coord, coords, indmax + 1, min_step)
309+
is_close_l = self.is_close(bound_coord, coords, indmax, min_step_check)
310+
is_close_r = self.is_close(bound_coord, coords, indmax + 1, min_step_check)
259311
is_contained = self.is_contained(bound_coord, bbox_contained_2d)
260312

261313
# Decide on whether coordinate should be inserted or indmax modified
@@ -278,7 +330,7 @@ def insert_bbox(
278330
if not self.is_contained(mid_coord, bbox_contained_2d):
279331
structs[interval_ind].append(str_ind)
280332

281-
return {"coords": coords, "structs": structs}
333+
return indmin >= indmax
282334

283335
@staticmethod
284336
def reorder_structures_enforced_to_end(
@@ -501,7 +553,7 @@ def filter_min_step(
501553
coords_filter = [interval_coords[0]]
502554
steps_filter = []
503555
for coord_ind, coord in enumerate(interval_coords[1:]):
504-
if coord - coords_filter[-1] > min_step:
556+
if coord - coords_filter[-1] >= min_step:
505557
coords_filter.append(coord)
506558
steps_filter.append(max_steps[coord_ind])
507559

0 commit comments

Comments
 (0)