Skip to content

Commit c5f03f3

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
1 parent c30ee71 commit c5f03f3

23 files changed

+2078
-611
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: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from tidy3d.components.base_sim.data.monitor_data import AbstractMonitorData
1919
from tidy3d.components.grid.grid import Coords, Grid
2020
from tidy3d.components.medium import Medium, MediumType
21+
from tidy3d.components.microwave.terminal_spec import TerminalSpec
2122
from tidy3d.components.monitor import (
2223
AuxFieldTimeMonitor,
2324
DiffractionMonitor,
@@ -1579,11 +1580,18 @@ class ModeData(ModeSolverDataset, ElectromagneticFieldData):
15791580

15801581
eps_spec: list[EpsSpecType] = pd.Field(
15811582
None,
1582-
title="Permettivity Specification",
1583+
title="Permittivity Specification",
15831584
description="Characterization of the permittivity profile on the plane where modes are "
15841585
"computed. Possible values are 'diagonal', 'tensorial_real', 'tensorial_complex'.",
15851586
)
15861587

1588+
terminal_spec: Optional[TerminalSpec] = pd.Field(
1589+
None,
1590+
title="Terminal Specification",
1591+
description="Specifies the method for defining microwave terminals, which is required "
1592+
"for defining the characteristic impedance of a transmission line mode.",
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):
@@ -2003,6 +2011,28 @@ def pol_fraction_waveguide(self) -> xr.Dataset:
20032011

20042012
return xr.Dataset(data_vars={"te": te_frac, "tm": tm_frac})
20052013

2014+
@cached_property
2015+
def line_impedance(self) -> FreqModeDataArray:
2016+
from tidy3d.components.microwave.path_integral_factory import (
2017+
make_current_integral,
2018+
make_voltage_integral,
2019+
)
2020+
from tidy3d.plugins.microwave.impedance_calculator import ImpedanceCalculator
2021+
2022+
if self.terminal_spec is None:
2023+
return None
2024+
v_spec = self.terminal_spec.voltage_spec
2025+
i_spec = self.terminal_spec.current_spec
2026+
if v_spec is None and i_spec is None:
2027+
return None
2028+
v_integral = make_voltage_integral(v_spec)
2029+
i_integral = make_current_integral(i_spec)
2030+
2031+
impedance_calc = ImpedanceCalculator(
2032+
voltage_integral=v_integral, current_integral=i_integral
2033+
)
2034+
return impedance_calc.compute_impedance(self)
2035+
20062036
@property
20072037
def modes_info(self) -> xr.Dataset:
20082038
"""Dataset collecting various properties of the stored modes."""
@@ -2032,6 +2062,10 @@ def modes_info(self) -> xr.Dataset:
20322062
info["wg TE fraction"] = self.pol_fraction_waveguide["te"]
20332063
info["wg TM fraction"] = self.pol_fraction_waveguide["tm"]
20342064

2065+
if self.line_impedance is not None:
2066+
info["Re(Z0)"] = self.line_impedance.real
2067+
info["Im(Z0)"] = self.line_impedance.imag
2068+
20352069
return xr.Dataset(data_vars=info)
20362070

20372071
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)