Skip to content

Commit fac5487

Browse files
committed
more flexible terminal specs
1 parent 299688b commit fac5487

File tree

8 files changed

+341
-77
lines changed

8 files changed

+341
-77
lines changed

tests/test_components/test_microwave.py

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -799,16 +799,16 @@ def test_mode_plane_analyzer_mode_bounds(mode_size, symmetry):
799799
y=td.Boundary(plus=td.PECBoundary(), minus=td.PECBoundary()),
800800
z=td.Boundary(plus=td.PECBoundary(), minus=td.PECBoundary()),
801801
)
802-
impedance_specs = (td.AutoImpedanceSpec(),) * 4
802+
impedance_specs = (td.AutoImpedanceSpec(),)
803803
mode_spec = td.MicrowaveModeSpec(
804-
num_modes=4,
804+
num_modes=1,
805805
target_neff=1.8,
806806
impedance_specs=impedance_specs,
807807
)
808808

809809
metal_box = td.Structure(
810810
geometry=td.Box.from_bounds(
811-
rmin=(0.5 * mm, 0.5 * mm, 0.5 * mm), rmax=(1 * mm - 1 * dl, 0.5 * mm, 0.5 * mm)
811+
rmin=(-1 * dl, -1 * dl, -0.5 * mm), rmax=(2 * dl, 2 * dl, 0.5 * mm)
812812
),
813813
medium=td.PEC,
814814
)
@@ -830,7 +830,7 @@ def test_mode_plane_analyzer_mode_bounds(mode_size, symmetry):
830830
simulation=sim,
831831
plane=mode_plane,
832832
mode_spec=mode_spec,
833-
colocate=True,
833+
colocate=False,
834834
freqs=[freq0],
835835
)
836836
mode_solver_boundaries = mms._solver_grid.boundaries.to_list
@@ -1828,3 +1828,105 @@ def test_validate_conductor_voltage_configurations():
18281828
]
18291829
with pytest.raises(ValueError, match="Duplicate voltage configuration"):
18301830
analyzer._validate_conductor_voltage_configurations(error_polarity_reversed)
1831+
1832+
1833+
def test_terminal_spec_valid_inputs():
1834+
"""Test TerminalSpec validation with valid inputs for plus_terminals and minus_terminals."""
1835+
# Test Case 1.1: Coordinate2D tuples (single coordinate)
1836+
spec = td.TerminalSpec(plus_terminals=((1.0, 0.5),), minus_terminals=((-1.0, 0.5),))
1837+
assert spec is not None
1838+
assert len(spec.plus_terminals) == 1
1839+
assert spec.plus_terminals[0] == (1.0, 0.5)
1840+
1841+
# Test Case 1.2: Multiple Coordinate2D tuples
1842+
spec = td.TerminalSpec(plus_terminals=((1.0, 0.5), (2.0, 0.5)), minus_terminals=())
1843+
assert len(spec.plus_terminals) == 2
1844+
1845+
# Test Case 1.3: String identifiers
1846+
spec = td.TerminalSpec(
1847+
plus_terminals=("conductor_1", "conductor_2"), minus_terminals=("ground",)
1848+
)
1849+
assert spec.plus_terminals[0] == "conductor_1"
1850+
assert spec.minus_terminals[0] == "ground"
1851+
1852+
# Test Case 1.4: ArrayFloat2D with 2 points (linestring)
1853+
spec = td.TerminalSpec(plus_terminals=(np.array([[0.0, 0.0], [1.0, 1.0]]),), minus_terminals=())
1854+
assert isinstance(spec.plus_terminals[0], np.ndarray)
1855+
assert spec.plus_terminals[0].shape == (2, 2)
1856+
1857+
# Test Case 1.5: ArrayFloat2D with 3+ points (polygon)
1858+
spec = td.TerminalSpec(
1859+
plus_terminals=(np.array([[0, 0], [1, 0], [1, 1], [0, 1]]),), minus_terminals=()
1860+
)
1861+
assert isinstance(spec.plus_terminals[0], np.ndarray)
1862+
assert spec.plus_terminals[0].shape == (4, 2)
1863+
1864+
# Test Case 1.6: Lists converted to numpy by ArrayFloat2D
1865+
spec = td.TerminalSpec(plus_terminals=([[0.0, 0.0], [1.0, 1.0]],), minus_terminals=())
1866+
# List should be converted to numpy array
1867+
assert isinstance(spec.plus_terminals[0], np.ndarray)
1868+
assert spec.plus_terminals[0].shape == (2, 2)
1869+
1870+
# Test Case 1.7: Mixed types in same spec
1871+
spec = td.TerminalSpec(
1872+
plus_terminals=(
1873+
(1.0, 0.5), # Coordinate2D
1874+
"conductor_2", # str
1875+
np.array([[0, 0], [1, 1]]), # ArrayFloat2D
1876+
),
1877+
minus_terminals=(),
1878+
)
1879+
assert isinstance(spec.plus_terminals[0], tuple)
1880+
assert isinstance(spec.plus_terminals[1], str)
1881+
assert isinstance(spec.plus_terminals[2], np.ndarray)
1882+
1883+
# Test Case 1.8: Empty minus_terminals (valid for common mode)
1884+
spec = td.TerminalSpec(plus_terminals=((1.0, 0.5),), minus_terminals=())
1885+
assert len(spec.minus_terminals) == 0
1886+
1887+
# Test Case 1.9: Exactly 3 points (minimum for polygon - triangle)
1888+
spec = td.TerminalSpec(
1889+
plus_terminals=(np.array([[0, 0], [1, 0], [0.5, 1]]),), minus_terminals=()
1890+
)
1891+
assert spec.plus_terminals[0].shape == (3, 2)
1892+
1893+
# Test Case 1.10: Exactly 2 points
1894+
spec = td.TerminalSpec(plus_terminals=(np.array([[0.0, 0.0], [1.0, 0.0]]),), minus_terminals=())
1895+
assert spec.plus_terminals[0].shape == (2, 2)
1896+
1897+
# Test Case 1.11: 1 point but using ArrayLike
1898+
spec = td.TerminalSpec(plus_terminals=(np.array([[1.0, 0.0]]),), minus_terminals=())
1899+
assert spec.plus_terminals[0].shape == (1, 2)
1900+
1901+
1902+
def test_terminal_spec_invalid_inputs():
1903+
"""Test TerminalSpec validation rejects invalid inputs."""
1904+
# Test Case 2.1: Invalid array shape (not Nx2)
1905+
with pytest.raises(pd.ValidationError):
1906+
td.TerminalSpec(
1907+
plus_terminals=(np.array([[0, 0, 0], [1, 1, 1]]),), # 3 columns instead of 2
1908+
minus_terminals=(),
1909+
)
1910+
1911+
# Test Case 2.2: Invalid polygon (self-intersecting bowtie)
1912+
with pytest.raises(pd.ValidationError, match="valid polygon"):
1913+
td.TerminalSpec(
1914+
plus_terminals=(
1915+
np.array([[0, 0], [1, 1], [1, 0], [0, 1]]),
1916+
), # Figure-8 self-intersection
1917+
minus_terminals=(),
1918+
)
1919+
1920+
# Test Case 2.3: 1D array instead of 2D
1921+
with pytest.raises(pd.ValidationError):
1922+
td.TerminalSpec(
1923+
plus_terminals=(np.array([0, 1, 2]),), # 1D array
1924+
minus_terminals=(),
1925+
)
1926+
1927+
# Test Case 2.4: Wrong coordinate tuple length (3 elements instead of 2)
1928+
with pytest.raises(pd.ValidationError):
1929+
td.TerminalSpec(
1930+
plus_terminals=((1.0, 0.5, 2.0),), # 3 elements
1931+
minus_terminals=(),
1932+
)

tests/test_components/test_simulation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3788,7 +3788,7 @@ def test_structures_per_medium(monkeypatch):
37883788

37893789

37903790
def test_validate_microwave_mode_spec():
3791-
"""Test that auto generation ande user supplied path specs are correctly validated."""
3791+
"""Test that auto generated and user supplied path specs are correctly validated."""
37923792
freq0 = 10e9
37933793
mm = 1e3
37943794
run_time_spec = td.RunTimeSpec(quality_factor=3.0)
@@ -3839,7 +3839,7 @@ def test_validate_microwave_mode_spec():
38393839

38403840
# check that validation error is caught
38413841
with pytest.raises(SetupError):
3842-
sim._validate_microwave_mode_specs()
3842+
sim._validate_microwave_mode_plane_analysis()
38433843

38443844
# Custom current spec is too large for mode plane
38453845
custom_spec = td.CustomImpedanceSpec(

tidy3d/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,7 @@
2424
MicrowaveModeSolverData,
2525
)
2626
from tidy3d.components.microwave.impedance_calculator import ImpedanceCalculator
27-
from tidy3d.components.microwave.mode_spec import (
28-
MicrowaveModeSpec,
29-
)
27+
from tidy3d.components.microwave.mode_spec import MicrowaveModeSpec, TerminalSpec
3028
from tidy3d.components.microwave.monitor import (
3129
MicrowaveModeMonitor,
3230
MicrowaveModeSolverMonitor,
@@ -799,6 +797,7 @@ def set_logging_level(level: str) -> None:
799797
"TemperatureBC",
800798
"TemperatureData",
801799
"TemperatureMonitor",
800+
"TerminalSpec",
802801
"TetrahedralGridDataset",
803802
"Transformed",
804803
"TriangleMesh",

tidy3d/components/data/monitor_data.py

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1819,31 +1819,6 @@ def _isel(self, **isel_kwargs: Any):
18191819
}
18201820
return self.updated_copy(**update_dict, deep=False, validate=False)
18211821

1822-
def _select_mode_subset(self, mode_inds: list[int]) -> Self:
1823-
"""Wraps ``xarray.DataArray.isel`` for all data fields that are defined over frequency and
1824-
mode index. Used in ``overlap_sort`` but not officially supported since for example
1825-
``self.monitor.mode_spec`` and ``self.monitor.freqs`` will no longer be matching the
1826-
newly created data."""
1827-
1828-
num_modes = len(mode_inds)
1829-
modify_data = {}
1830-
for key, data in self.data_arrs.items():
1831-
if "mode_index" not in data.dims:
1832-
continue
1833-
# dims_orig = data.dims
1834-
# f_coord = data.coords["f"]
1835-
# slices = []
1836-
# for ifreq in range(num_freqs):
1837-
# sl = data.isel(f=ifreq, mode_index=sort_inds_2d[ifreq])
1838-
# slices.append(sl.assign_coords(mode_index=np.arange(num_modes)))
1839-
# # Concatenate along the 'f' dimension name and then restore original frequency coordinates
1840-
# data = xr.concat(slices, dim="f").assign_coords(f=f_coord).transpose(*dims_orig)
1841-
modify_data[key] = data.sel(mode_index=mode_inds).assign_coords(
1842-
mode_index=np.arange(num_modes)
1843-
)
1844-
subset_data = self.updated_copy(**modify_data)
1845-
return subset_data.updated_copy(path="monitor/mode_spec", num_modes=num_modes)
1846-
18471822
def _assign_coords(self, **assign_coords_kwargs: Any):
18481823
"""Wraps ``xarray.DataArray.assign_coords`` for all data fields that are defined over frequency and
18491824
mode index. Used in ``overlap_sort`` but not officially supported since for example

tidy3d/components/microwave/mode_spec.py

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import numpy as np
88
import pydantic.v1 as pd
9+
from shapely import Polygon
910

1011
from tidy3d.components.base import cached_property
1112
from tidy3d.components.geometry.base import Box
@@ -17,22 +18,103 @@
1718
)
1819
from tidy3d.components.mode_spec import AbstractModeSpec
1920
from tidy3d.components.types import Coordinate2D, annotate_type
21+
from tidy3d.components.types.base import ArrayFloat2D
2022
from tidy3d.constants import fp_eps
2123
from tidy3d.exceptions import SetupError
2224

25+
ConductorIdentifierType = Union[Coordinate2D, str, ArrayFloat2D]
26+
2327

2428
class TerminalSpec(MicrowaveBaseModel):
25-
plus_terminals: tuple[Coordinate2D, ...] = pd.Field(
29+
"""Specifies the desired voltage pattern across conductors for a transmission line mode.
30+
31+
Identifies which conductors should be at positive versus negative voltage for mode selection
32+
and ordering in coupled transmission line systems. Conductors can be identified using point
33+
coordinates, structure names, or geometric regions for maximum flexibility.
34+
35+
Notes
36+
-----
37+
For most use cases, identifying conductors using a single (x, y) coordinate point or a
38+
structure name string is recommended. If desired, users may specify geometric regions using
39+
arrays of vertices, which are then converted to points, lines, or polygons to identify
40+
conductors via intersections.
41+
42+
Example
43+
-------
44+
>>> # Recommended: Identify conductors using point coordinates
45+
>>> # Differential mode: conductor 1 positive, conductor 2 negative
46+
>>> diff_mode = TerminalSpec(
47+
... plus_terminals=((1.0, 0.5),), # Point inside positive conductor
48+
... minus_terminals=((-1.0, 0.5),) # Point inside negative conductor
49+
... )
50+
>>>
51+
>>> # Common mode: both conductors positive relative to ground
52+
>>> common_mode = TerminalSpec(
53+
... plus_terminals=((1.0, 0.5), (-1.0, 0.5)), # Both conductors positive
54+
... minus_terminals=() # Ground plane is reference
55+
... )
56+
>>>
57+
>>> # Alternative: Identify conductors by structure name
58+
>>> named_mode = TerminalSpec(
59+
... plus_terminals=("trace1",),
60+
... minus_terminals=("trace2",)
61+
... )
62+
>>>
63+
>>> # Advanced: Identify conductor using polygon region
64+
>>> import numpy as np
65+
>>> polygon_mode = TerminalSpec(
66+
... plus_terminals=(np.array([[0, 0], [1, 0], [1, 1], [0, 1]]),), # Square region
67+
... minus_terminals=()
68+
... )
69+
"""
70+
71+
plus_terminals: tuple[ConductorIdentifierType, ...] = pd.Field(
2672
...,
27-
title="Impedance Specifications",
28-
description="Field controls how the impedan",
73+
title="Positive Terminals",
74+
description="Identifies conductors that should be at positive voltage for this mode. "
75+
"Each conductor can be specified as: (1) a (u, v) coordinate tuple locating a point inside "
76+
"the conductor (recommended), (2) a string matching a structure name, or (3) an Nx2 array "
77+
"of vertices defining a geometric region (point for N=1, line for N=2, polygon for N>2). "
78+
"The coordinate or region should lie within the desired conductor in the mode plane.",
2979
)
30-
minus_terminals: tuple[Coordinate2D, ...] = pd.Field(
80+
minus_terminals: tuple[ConductorIdentifierType, ...] = pd.Field(
3181
...,
32-
title="Impedance Specifications",
33-
description="Field controls how the impedan",
82+
title="Negative Terminals",
83+
description="Identifies conductors that should be at negative voltage for this mode. "
84+
"Each conductor can be specified as: (1) a (u, v) coordinate tuple locating a point inside "
85+
"the conductor (recommended), (2) a string matching a structure name, or (3) an Nx2 array "
86+
"of vertices defining a geometric region (point for N=1, line for N=2, polygon for N>2). "
87+
"The coordinate or region should lie within the desired conductor in the mode plane.",
3488
)
3589

90+
@pd.validator("plus_terminals", "minus_terminals", each_item=True)
91+
def _validate_conductor_identifiers(cls, val):
92+
"""Validate conductor identification inputs."""
93+
94+
# If it's a string or tuple, pass through
95+
if isinstance(val, (str, tuple)):
96+
return val
97+
98+
# If it's a numpy array, validate shape and geometry
99+
if isinstance(val, np.ndarray):
100+
# Check that 2D arrays have exactly 2 columns (u, v coordinates)
101+
if val.shape[1] != 2:
102+
raise ValueError(
103+
f"Arrays must have exactly 2 columns for (u, v) coordinates, got shape {val.shape}"
104+
)
105+
# For 2D arrays, check number of points and polygon validity
106+
num_points = val.shape[0]
107+
if num_points <= 2:
108+
return val
109+
elif num_points > 2:
110+
polygon = Polygon(val)
111+
if not polygon.is_valid:
112+
raise ValueError(
113+
f"A supplied set of vertices {val} did not result in a valid "
114+
"polygon, make sure there are no self-intersections."
115+
)
116+
return val
117+
36118

37119
class MicrowaveModeSpec(AbstractModeSpec, MicrowaveBaseModel):
38120
"""
@@ -80,11 +162,11 @@ class MicrowaveModeSpec(AbstractModeSpec, MicrowaveBaseModel):
80162
terminal_specs: Optional[tuple[TerminalSpec, ...]] = pd.Field(
81163
None,
82164
title="Terminal Specifications",
83-
description="Field controls how the impedance is calculated for each mode calculated by the mode solver. "
84-
"Can be a single impedance specification (which will be applied to all modes) or a tuple of specifications "
85-
"(one per mode). The number of impedance specifications should match the number of modes field. "
86-
"When an impedance specification of ``None`` is used, the impedance calculation will be "
87-
"ignored for the associated mode.",
165+
description="Optional tuple of terminal specifications for mode selection and ordering in"
166+
"transmission line systems. Each 'TerminalSpec' defines the desired voltage pattern (which conductors "
167+
"should be positive vs. negative) for a mode. When provided, the mode solver automatically reorders "
168+
"computed modes to match the terminal specification order and applies phase corrections to ensure "
169+
"correct voltage polarity.",
88170
)
89171

90172
@cached_property

0 commit comments

Comments
 (0)