Skip to content

Commit 3f3195e

Browse files
committed
add a TerminalSpec for RF specific mode information
adding support for colocated fields fixing bug for more complicated shapes adding tests, fixing symmetric conditions, fixing for 2D structures fix simulation validator for terminal spec refactor by splitting the path integral specification away from the integral computation, now the path specification handles pretty much everything, except for the final integral computation and results preparation rename auto patch spec to generator type name, fixed tests add test for sim validation and improved doc strings fix regression fix python 3.9 tests rebase on ruff changes reorg of ModeData, the impedance is now calculated by the ModeSolver class avoiding the need for a duplicate terminal spec
1 parent 42e0474 commit 3f3195e

20 files changed

+2049
-589
lines changed

tests/test_components/test_microwave.py

Lines changed: 372 additions & 2 deletions
Large diffs are not rendered by default.

tests/test_components/test_simulation.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3645,3 +3645,54 @@ def test_create_sim_multiphysics_with_incompatibilities():
36453645
),
36463646
],
36473647
)
3648+
3649+
3650+
def test_validate_terminal_spec_generation():
3651+
"""Test that auto generation of path specs is correctly validated for currently unsupported structures."""
3652+
freq0 = 10e9
3653+
mm = 1e3
3654+
run_time_spec = td.RunTimeSpec(quality_factor=3.0)
3655+
size = (10 * mm, 10 * mm, 10 * mm)
3656+
size_mon = (0, 8 * mm, 8 * mm)
3657+
3658+
# Currently limited to generation of axis aligned boxes around conductors,
3659+
# so the path may intersect other nearby conductors, like in this coaxial cable
3660+
coaxial = td.Structure(
3661+
geometry=td.GeometryGroup(
3662+
geometries=(
3663+
td.ClipOperation(
3664+
operation="difference",
3665+
geometry_a=td.Cylinder(
3666+
axis=0, radius=2.5 * mm, center=(0, 0, 0), length=td.inf
3667+
),
3668+
geometry_b=td.Cylinder(
3669+
axis=0, radius=1.3 * mm, center=(0, 0, 0), length=td.inf
3670+
),
3671+
),
3672+
td.Cylinder(axis=0, radius=1 * mm, center=(0, 0, 0), length=td.inf),
3673+
)
3674+
),
3675+
medium=td.PEC,
3676+
)
3677+
mode_spec = td.ModeSpec(num_modes=2, target_neff=1.8, terminal_spec=td.TerminalSpec())
3678+
3679+
mode_mon = td.ModeMonitor(
3680+
center=(0, 0, 0),
3681+
size=size_mon,
3682+
freqs=[freq0],
3683+
name="mode_1",
3684+
colocate=False,
3685+
mode_spec=mode_spec,
3686+
)
3687+
sim = td.Simulation(
3688+
run_time=run_time_spec,
3689+
size=size,
3690+
sources=[],
3691+
structures=[coaxial],
3692+
grid_spec=td.GridSpec.uniform(dl=0.1 * mm),
3693+
monitors=[mode_mon],
3694+
)
3695+
3696+
# check that validation error is caught
3697+
with pytest.raises(SetupError):
3698+
sim._validate_terminal_specs()

tests/test_plugins/test_microwave.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@
1919

2020
from ..utils import get_spatial_coords_dict, run_emulated
2121

22+
MAKE_PLOTS = False
23+
if MAKE_PLOTS:
24+
# Interative plotting for debugging
25+
from matplotlib import use
26+
27+
use("TkAgg")
28+
2229
# Using similar code as "test_data/test_data_arrays.py"
2330
MON_SIZE = (2, 1, 0)
2431
FIELDS = ("Ex", "Ey", "Hx", "Hy")
@@ -785,13 +792,11 @@ def test_lobe_measurements(apply_cyclic_extension, include_endpoint):
785792
@pytest.mark.parametrize("min_value", [0.0, 1.0])
786793
def test_lobe_plots(min_value):
787794
"""Run the lobe measurer on some test data and plot the results."""
788-
# Interative plotting for debugging
789-
# from matplotlib import use
790-
# use("TkAgg")
791795
theta = np.linspace(0, 2 * np.pi, 301)
792796
Urad = np.cos(theta) ** 2 * np.cos(3 * theta) ** 2 + min_value
793797
lobe_measurer = mw.LobeMeasurer(angle=theta, radiation_pattern=Urad)
794798
_, ax = plt.subplots(1, 1, subplot_kw={"projection": "polar"})
795799
ax.plot(theta, Urad, "k")
796800
lobe_measurer.plot(0, ax)
797-
plt.show()
801+
if MAKE_PLOTS:
802+
plt.show()

tidy3d/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,16 @@
1717
from tidy3d.components.microwave.data.monitor_data import (
1818
AntennaMetricsData,
1919
)
20+
from tidy3d.components.microwave.path_spec import (
21+
CompositeCurrentIntegralSpec,
22+
CurrentIntegralAxisAlignedSpec,
23+
CustomCurrentIntegral2DSpec,
24+
CustomVoltageIntegral2DSpec,
25+
VoltageIntegralAxisAlignedSpec,
26+
)
27+
from tidy3d.components.microwave.terminal_spec import (
28+
TerminalSpec,
29+
)
2030
from tidy3d.components.spice.analysis.dc import (
2131
ChargeToleranceSpec,
2232
IsothermalSteadyChargeDCAnalysis,
@@ -439,6 +449,7 @@ def set_logging_level(level: str) -> None:
439449
"ChargeToleranceSpec",
440450
"ClipOperation",
441451
"CoaxialLumpedResistor",
452+
"CompositeCurrentIntegralSpec",
442453
"ConstantDoping",
443454
"ConstantMobilityModel",
444455
"ContinuousWave",
@@ -449,8 +460,10 @@ def set_logging_level(level: str) -> None:
449460
"Coords1D",
450461
"CornerFinderSpec",
451462
"CurrentBC",
463+
"CurrentIntegralAxisAlignedSpec",
452464
"CustomAnisotropicMedium",
453465
"CustomChargePerturbation",
466+
"CustomCurrentIntegral2DSpec",
454467
"CustomCurrentSource",
455468
"CustomDebye",
456469
"CustomDrude",
@@ -463,6 +476,7 @@ def set_logging_level(level: str) -> None:
463476
"CustomPoleResidue",
464477
"CustomSellmeier",
465478
"CustomSourceTime",
479+
"CustomVoltageIntegral2DSpec",
466480
"Cylinder",
467481
"DCCurrentSource",
468482
"DCVoltageSource",
@@ -668,6 +682,7 @@ def set_logging_level(level: str) -> None:
668682
"TemperatureBC",
669683
"TemperatureData",
670684
"TemperatureMonitor",
685+
"TerminalSpec",
671686
"TetrahedralGridDataset",
672687
"Transformed",
673688
"TriangleMesh",
@@ -682,6 +697,7 @@ def set_logging_level(level: str) -> None:
682697
"Updater",
683698
"VisualizationSpec",
684699
"VoltageBC",
700+
"VoltageIntegralAxisAlignedSpec",
685701
"VoltageSourceType",
686702
"VolumetricAveraging",
687703
"YeeGrid",

tidy3d/components/data/monitor_data.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1579,11 +1579,19 @@ class ModeData(ModeSolverDataset, ElectromagneticFieldData):
15791579

15801580
eps_spec: list[EpsSpecType] = pd.Field(
15811581
None,
1582-
title="Permettivity Specification",
1582+
title="Permittivity Specification",
15831583
description="Characterization of the permittivity profile on the plane where modes are "
15841584
"computed. Possible values are 'diagonal', 'tensorial_real', 'tensorial_complex'.",
15851585
)
15861586

1587+
Z0: Optional[FreqModeDataArray] = pd.Field(
1588+
None,
1589+
title="Characteristic Impedance",
1590+
description="Optional quantity calculated for transmission lines."
1591+
"The characteristic impedance is only calculated when a :class:``TerminalSpec`` "
1592+
"is provided to the :class:``ModeSpec`` associated with this data.",
1593+
)
1594+
15871595
@pd.validator("eps_spec", always=True)
15881596
@skip_if_fields_missing(["monitor"])
15891597
def eps_spec_match_mode_spec(cls, val, values):
@@ -2032,6 +2040,10 @@ def modes_info(self) -> xr.Dataset:
20322040
info["wg TE fraction"] = self.pol_fraction_waveguide["te"]
20332041
info["wg TM fraction"] = self.pol_fraction_waveguide["tm"]
20342042

2043+
if self.Z0 is not None:
2044+
info["Re(Z0)"] = self.Z0.real
2045+
info["Im(Z0)"] = self.Z0.imag
2046+
20352047
return xr.Dataset(data_vars=info)
20362048

20372049
def to_dataframe(self) -> DataFrame:

tidy3d/components/geometry/utils.py

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,21 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Iterable
56
from enum import Enum
67
from math import isclose
78
from typing import Any, Optional, Union
89

910
import numpy as np
10-
import pydantic
11+
import pydantic.v1 as pydantic
12+
from shapely.geometry import (
13+
GeometryCollection,
14+
MultiLineString,
15+
MultiPoint,
16+
MultiPolygon,
17+
Polygon,
18+
)
19+
from shapely.geometry.base import BaseGeometry
1120

1221
from tidy3d.components.base import Tidy3dBaseModel
1322
from tidy3d.components.geometry.base import Box
@@ -38,6 +47,46 @@
3847
]
3948

4049

50+
def flatten_shapely_geometries(
51+
geoms: Union[Shapely, Iterable[Shapely]], keep_types: tuple[type, ...] = (Polygon,)
52+
) -> list[Shapely]:
53+
"""
54+
Flatten nested geometries into a flat list, while only keeping the specified types.
55+
56+
Recursively extracts and returns non-empty geometries of the given types from input geometries,
57+
expanding any GeometryCollections or Multi* types.
58+
59+
Parameters
60+
----------
61+
geoms : Union[Shapely, Iterable[Shapely]]
62+
Input geometries to flatten.
63+
64+
keep_types : tuple[type, ...]
65+
Geometry types to keep (e.g., (Polygon, LineString)). Default is
66+
(Polygon).
67+
68+
Returns
69+
-------
70+
list[Shapely]
71+
Flat list of non-empty geometries matching the specified types.
72+
"""
73+
# Handle single Shapely object by wrapping it in a list
74+
if isinstance(geoms, Shapely):
75+
geoms = [geoms]
76+
77+
flat = []
78+
for geom in geoms:
79+
if geom.is_empty:
80+
continue
81+
if isinstance(geom, keep_types):
82+
flat.append(geom)
83+
elif isinstance(geom, (MultiPolygon, MultiLineString, MultiPoint, GeometryCollection)):
84+
flat.extend(flatten_shapely_geometries(geom.geoms, keep_types))
85+
elif isinstance(geom, BaseGeometry) and hasattr(geom, "geoms"):
86+
flat.extend(flatten_shapely_geometries(geom.geoms, keep_types))
87+
return flat
88+
89+
4190
def merging_geometries_on_plane(
4291
geometries: list[GeometryType],
4392
plane: Box,
@@ -373,6 +422,15 @@ class SnappingSpec(Tidy3dBaseModel):
373422
description="Describes how snapping positions will be chosen.",
374423
)
375424

425+
margin: Optional[
426+
tuple[pydantic.NonNegativeInt, pydantic.NonNegativeInt, pydantic.NonNegativeInt]
427+
] = pydantic.Field(
428+
(0, 0, 0),
429+
title="Margin",
430+
description="Number of additional grid points to consider when expanding or contracting "
431+
"during snapping. Only applies when ``SnapBehavior`` is ``Expand`` or ``Contract``.",
432+
)
433+
376434

377435
def get_closest_value(test: float, coords: np.ArrayLike, upper_bound_idx: int) -> float:
378436
"""Helper to choose the closest value in an array to a given test value,
@@ -404,6 +462,8 @@ def get_lower_bound(
404462
using the index of the upper bound. If the test value is close to the upper
405463
bound, it assumes they are equal, and in that case the upper bound is returned.
406464
"""
465+
upper_bound_idx = min(upper_bound_idx, len(coords))
466+
upper_bound_idx = max(upper_bound_idx, 0)
407467
if upper_bound_idx == len(coords):
408468
return coords[upper_bound_idx - 1]
409469
if upper_bound_idx == 0 or isclose(coords[upper_bound_idx], test, rel_tol=rel_tol):
@@ -417,14 +477,20 @@ def get_upper_bound(
417477
using the index of the upper bound. If the test value is close to the lower
418478
bound, it assumes they are equal, and in that case the lower bound is returned.
419479
"""
480+
upper_bound_idx = min(upper_bound_idx, len(coords))
481+
upper_bound_idx = max(upper_bound_idx, 0)
420482
if upper_bound_idx == len(coords):
421483
return coords[upper_bound_idx - 1]
422484
if upper_bound_idx > 0 and isclose(coords[upper_bound_idx - 1], test, rel_tol=rel_tol):
423485
return coords[upper_bound_idx - 1]
424486
return coords[upper_bound_idx]
425487

426488
def find_snapping_locations(
427-
interval_min: float, interval_max: float, coords: np.ndarray, snap_type: SnapBehavior
489+
interval_min: float,
490+
interval_max: float,
491+
coords: np.ndarray,
492+
snap_type: SnapBehavior,
493+
snap_margin: pydantic.NonNegativeInt,
428494
) -> tuple[float, float]:
429495
"""Helper that snaps a supplied interval [interval_min, interval_max] to a
430496
sorted array representing coordinate values.
@@ -436,9 +502,15 @@ def find_snapping_locations(
436502
min_snap = get_closest_value(interval_min, coords, min_upper_bound_idx)
437503
max_snap = get_closest_value(interval_max, coords, max_upper_bound_idx)
438504
elif snap_type == SnapBehavior.Expand:
505+
min_upper_bound_idx -= snap_margin
506+
max_upper_bound_idx += snap_margin
439507
min_snap = get_lower_bound(interval_min, coords, min_upper_bound_idx, rel_tol=rtol)
440508
max_snap = get_upper_bound(interval_max, coords, max_upper_bound_idx, rel_tol=rtol)
441509
else: # SnapType.Contract
510+
min_upper_bound_idx += snap_margin
511+
max_upper_bound_idx -= snap_margin
512+
if max_upper_bound_idx < min_upper_bound_idx:
513+
raise SetupError("The supplied 'snap_buffer' is too large for this contraction.")
442514
min_snap = get_upper_bound(interval_min, coords, min_upper_bound_idx, rel_tol=rtol)
443515
max_snap = get_lower_bound(interval_max, coords, max_upper_bound_idx, rel_tol=rtol)
444516
return (min_snap, max_snap)
@@ -450,6 +522,7 @@ def find_snapping_locations(
450522
for axis in range(3):
451523
snap_location = snap_spec.location[axis]
452524
snap_type = snap_spec.behavior[axis]
525+
snap_margin = snap_spec.margin[axis]
453526
if snap_type == SnapBehavior.Off:
454527
continue
455528
if snap_location == SnapLocation.Boundary:
@@ -460,7 +533,9 @@ def find_snapping_locations(
460533
box_min = min_b[axis]
461534
box_max = max_b[axis]
462535

463-
(new_min, new_max) = find_snapping_locations(box_min, box_max, snap_coords, snap_type)
536+
(new_min, new_max) = find_snapping_locations(
537+
box_min, box_max, snap_coords, snap_type, snap_margin
538+
)
464539
min_b[axis] = new_min
465540
max_b[axis] = new_max
466541
return Box.from_bounds(min_b, max_b)

0 commit comments

Comments
 (0)