diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf63806cd..e7f6704b9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `Grid.fine_mesh_info` property to identify and report locations where grid cell sizes are fine for understanding meshing hotspots. - Added visualization of finest grid regions in `Simulation.plot_grid()` with shaded regions highlighting areas of fine meshing. - Added autograd support for `Sphere`. +- Added validation warning in `HeatChargeSimulation` for very small `Cylinder` radii to help users avoid meshing and numerical issues. - Added `GaussianOverlapMonitor` and `AstigmaticGaussianOverlapMonitor` for decomposing electromagnetic fields onto Gaussian beam profiles. - Added `GaussianPort` and `AstigmaticGaussianPort` for S-matrix calculations using Gaussian beam sources and overlap monitors. - Added `symmetric_pseudo` option for `s_param_def` in `TerminalComponentModeler` which applies a scaling factor that ensures the S-matrix is symmetric in reciprocal systems. diff --git a/tests/test_components/test_heat_charge.py b/tests/test_components/test_heat_charge.py index c0a2341bfe..c0923c45da 100644 --- a/tests/test_components/test_heat_charge.py +++ b/tests/test_components/test_heat_charge.py @@ -2832,3 +2832,72 @@ def test_heat_charge_simulation_plot(): "for CHARGE simulations, resulting in at least 2 more visual elements " "than charge_sim.scene.plot()" ) + + +def test_cylinder_small_radius_warning(): + """Test that warning is issued for very small cylinder radii in HeatChargeSimulation.""" + solid = td.MultiPhysicsMedium( + heat=td.SolidSpec(conductivity=1, capacity=1), + name="solid", + ) + background = td.MultiPhysicsMedium( + heat=td.FluidSpec(), + name="background", + ) + + # Test non-tapered cylinder with tiny radius + tiny_cylinder = td.Structure( + geometry=td.Cylinder(center=(0, 0, 0), radius=1e-8, length=1, axis=2), + medium=solid, + name="tiny", + ) + with AssertLogLevel("WARNING", contains_str="radius"): + _ = td.HeatChargeSimulation( + center=(0, 0, 0), + size=(2, 2, 2), + medium=background, + structures=[tiny_cylinder], + grid_spec=td.UniformUnstructuredGrid(dl=0.1), + monitors=[td.TemperatureMonitor(size=(1, 1, 1), name="tmp")], + ) + + # Test transformed (translated) cylinder with tiny radius + tiny_cylinder_transformed = td.Structure( + geometry=td.Cylinder(center=(0, 0, 0), radius=1e-8, length=1, axis=2).translated( + x=0.1, y=0.0, z=0.0 + ), + medium=solid, + name="tiny_transformed", + ) + with AssertLogLevel("WARNING", contains_str="radius"): + _ = td.HeatChargeSimulation( + center=(0, 0, 0), + size=(2, 2, 2), + medium=background, + structures=[tiny_cylinder_transformed], + grid_spec=td.UniformUnstructuredGrid(dl=0.1), + monitors=[td.TemperatureMonitor(size=(1, 1, 1), name="tmp")], + ) + + # Test tapered cylinder with steep sidewall causing negative radius_top + tapered_cylinder = td.Structure( + geometry=td.Cylinder( + center=(0, 0, 0), + radius=0.1, + length=1, + axis=2, + sidewall_angle=np.pi / 3, # 60 degrees - causes negative radius_top + reference_plane="bottom", + ), + medium=solid, + name="tapered", + ) + with AssertLogLevel("WARNING", contains_str="radius_top"): + _ = td.HeatChargeSimulation( + center=(0, 0, 0), + size=(2, 2, 2), + medium=background, + structures=[tapered_cylinder], + grid_spec=td.UniformUnstructuredGrid(dl=0.1), + monitors=[td.TemperatureMonitor(size=(1, 1, 1), name="tmp")], + ) diff --git a/tidy3d/components/geometry/utils.py b/tidy3d/components/geometry/utils.py index 4663572366..dac63cd598 100644 --- a/tidy3d/components/geometry/utils.py +++ b/tidy3d/components/geometry/utils.py @@ -249,7 +249,8 @@ def flatten_groups( def traverse_geometries(geometry: GeometryType) -> GeometryType: """Iterator over all geometries within the given geometry. - Iterates over groups and clip operations within the given geometry, yielding each one. + Iterates over groups, clip operations, and transformed geometries within the given geometry, + yielding each one. Parameters ---------- @@ -267,6 +268,8 @@ def traverse_geometries(geometry: GeometryType) -> GeometryType: elif isinstance(geometry, base.ClipOperation): yield from traverse_geometries(geometry.geometry_a) yield from traverse_geometries(geometry.geometry_b) + elif isinstance(geometry, base.Transformed): + yield from traverse_geometries(geometry.geometry) yield geometry diff --git a/tidy3d/components/tcad/simulation/heat_charge.py b/tidy3d/components/tcad/simulation/heat_charge.py index 7fe6c62767..5e66284142 100644 --- a/tidy3d/components/tcad/simulation/heat_charge.py +++ b/tidy3d/components/tcad/simulation/heat_charge.py @@ -24,6 +24,8 @@ StructureStructureInterface, ) from tidy3d.components.geometry.base import Box +from tidy3d.components.geometry.primitives import Cylinder +from tidy3d.components.geometry.utils import traverse_geometries from tidy3d.components.material.tcad.charge import ( ChargeConductorMedium, SemiconductorMedium, @@ -127,6 +129,11 @@ # define some limits for transient heat simulations TRANSIENT_HEAT_MAX_STEPS = 1000 +# OpenCASCADE minimum tolerance for cylinder radii +OPENCASCADE_CYLINDER_RADIUS_TOL = 1e-6 +# Minimum radius as fraction of the larger radius (for tapered cylinders) +MIN_CYLINDER_RADIUS_FRACTION = 0.01 + class TCADAnalysisTypes(str, Enum): """Enumeration of the types of simulations currently supported""" @@ -363,6 +370,45 @@ def check_unsupported_geometries(cls, val): ) return val + @pd.validator("structures", always=True) + def _warn_small_cylinder_radius(cls, val): + """Warn if any Cylinder geometry has radius too small for meshing.""" + for structure in val: + for geometry in traverse_geometries(structure.geometry): + if isinstance(geometry, Cylinder): + r_bottom = geometry.radius_bottom + r_top = geometry.radius_top + is_tapered = not np.isclose(r_bottom, r_top) + + # Compute minimum allowed radius (matches backend heat_mesh.py logic) + min_radius = max( + OPENCASCADE_CYLINDER_RADIUS_TOL, + MIN_CYLINDER_RADIUS_FRACTION * max(abs(r_bottom), abs(r_top)), + ) + + # Warn if radii are below minimum + if is_tapered: + if r_bottom < min_radius: + log.warning( + f"Cylinder 'radius_bottom' ({r_bottom:.3e}) is below the minimum " + f"radius for meshing ({min_radius:.3e}). The sidewall angle may be " + f"too steep. Will be clamped to minimum radius or mesh size, whichever is larger." + ) + if r_top < min_radius: + log.warning( + f"Cylinder 'radius_top' ({r_top:.3e}) is below the minimum " + f"radius for meshing ({min_radius:.3e}). The sidewall angle may be " + f"too steep. Will be clamped to minimum radius or mesh size, whichever is larger." + ) + else: + if r_bottom < min_radius: + log.warning( + f"Cylinder 'radius' ({r_bottom:.3e}) is below the minimum " + f"radius for meshing ({min_radius:.3e}). " + f"Will be clamped to minimum radius or mesh size, whichever is larger." + ) + return val + @staticmethod def _check_cross_solids(objs: tuple[Box, ...], values: dict) -> tuple[int, ...]: """Given model dictionary ``values``, check whether objects in list ``objs`` cross