Skip to content

Commit 299688b

Browse files
committed
feat(rf): add voltage-based selection of modes to the ModeSolver
1 parent 784d3c9 commit 299688b

File tree

5 files changed

+158
-4
lines changed

5 files changed

+158
-4
lines changed

tests/test_components/test_microwave.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1794,3 +1794,37 @@ def test_RF_license_suppression():
17941794
with AssertLogLevel(None):
17951795
mode_spec = td.MicrowaveModeSpec._default_without_license_warning()
17961796
td.config.microwave.suppress_rf_license_warning = original_setting
1797+
1798+
1799+
def test_validate_conductor_voltage_configurations():
1800+
"""Test validation of conductor voltage configurations in ModePlaneAnalyzer.
1801+
1802+
Tests common user errors:
1803+
1. Valid configuration
1804+
2. Conductor assigned to both positive and negative terminals
1805+
3. Polarity-reversed duplicates (most subtle user error)
1806+
"""
1807+
analyzer = ModePlaneAnalyzer(size=(0, 2, 2), field_data_colocated=False)
1808+
1809+
# Valid: multiple terminal configurations with different conductors
1810+
valid_config = [
1811+
({0}, {1}), # Terminal 1: conductor 0 (+), conductor 1 (-)
1812+
({2}, {3}), # Terminal 2: conductor 2 (+), conductor 3 (-)
1813+
]
1814+
analyzer._validate_conductor_voltage_configurations(valid_config) # Should not raise
1815+
1816+
# ERROR: Conductor in both positive and negative sets
1817+
error_both_polarities = [
1818+
({0, 1}, {1, 2}) # Conductor 1 is in BOTH positive and negative!
1819+
]
1820+
with pytest.raises(ValueError, match="cannot be assigned to both a positive and negative"):
1821+
analyzer._validate_conductor_voltage_configurations(error_both_polarities)
1822+
1823+
# ERROR: Polarity-reversed duplicates (most common user error!)
1824+
# ({0}, {1}) and ({1}, {0}) represent the same terminal with reversed polarity
1825+
error_polarity_reversed = [
1826+
({0}, {1}), # Terminal 1: conductor 0 (+), conductor 1 (-)
1827+
({1}, {0}), # Terminal 2: conductor 1 (+), conductor 0 (-) <- SAME TERMINAL!
1828+
]
1829+
with pytest.raises(ValueError, match="Duplicate voltage configuration"):
1830+
analyzer._validate_conductor_voltage_configurations(error_polarity_reversed)

tidy3d/components/data/monitor_data.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1819,6 +1819,31 @@ 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+
18221847
def _assign_coords(self, **assign_coords_kwargs: Any):
18231848
"""Wraps ``xarray.DataArray.assign_coords`` for all data fields that are defined over frequency and
18241849
mode index. Used in ``overlap_sort`` but not officially supported since for example

tidy3d/components/microwave/mode_spec.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,24 @@
1616
ImpedanceSpecType,
1717
)
1818
from tidy3d.components.mode_spec import AbstractModeSpec
19-
from tidy3d.components.types import annotate_type
19+
from tidy3d.components.types import Coordinate2D, annotate_type
2020
from tidy3d.constants import fp_eps
2121
from tidy3d.exceptions import SetupError
2222

2323

24+
class TerminalSpec(MicrowaveBaseModel):
25+
plus_terminals: tuple[Coordinate2D, ...] = pd.Field(
26+
...,
27+
title="Impedance Specifications",
28+
description="Field controls how the impedan",
29+
)
30+
minus_terminals: tuple[Coordinate2D, ...] = pd.Field(
31+
...,
32+
title="Impedance Specifications",
33+
description="Field controls how the impedan",
34+
)
35+
36+
2437
class MicrowaveModeSpec(AbstractModeSpec, MicrowaveBaseModel):
2538
"""
2639
The :class:`.MicrowaveModeSpec` class specifies how quantities related to transmission line
@@ -64,6 +77,16 @@ class MicrowaveModeSpec(AbstractModeSpec, MicrowaveBaseModel):
6477
"ignored for the associated mode.",
6578
)
6679

80+
terminal_specs: Optional[tuple[TerminalSpec, ...]] = pd.Field(
81+
None,
82+
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.",
88+
)
89+
6790
@cached_property
6891
def _impedance_specs_as_tuple(self) -> tuple[Optional[ImpedanceSpecType]]:
6992
"""Gets the impedance_specs field converted to a tuple."""

tidy3d/components/microwave/path_integrals/mode_plane_analyzer.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import pydantic.v1 as pd
99
import shapely
10-
from shapely.geometry import LineString, Polygon
10+
from shapely.geometry import LineString, Point, Polygon
1111

1212
from tidy3d.components.base import cached_property
1313
from tidy3d.components.geometry.base import Box, Geometry
@@ -21,11 +21,16 @@
2121
)
2222
from tidy3d.components.grid.grid import Grid
2323
from tidy3d.components.medium import LossyMetalMedium, Medium
24+
from tidy3d.components.microwave.mode_spec import TerminalSpec
2425
from tidy3d.components.structure import Structure
2526
from tidy3d.components.types import Axis, Bound, Coordinate, Shapely, Symmetry
2627
from tidy3d.components.validators import assert_plane
2728
from tidy3d.exceptions import SetupError
2829

30+
# Type for holding sets of indices associated with conductors,
31+
# where the first set contains positive terminals and the second set contains negative terminals.
32+
VoltageSets = tuple[set[int], set[int]]
33+
2934

3035
class ModePlaneAnalyzer(Box):
3136
"""Analyzes conductor geometry intersecting a mode plane.
@@ -246,6 +251,61 @@ def bounding_box_from_shapely(geom: Shapely) -> Box:
246251
)
247252
return bounding_boxes, conductor_shapely
248253

254+
def _find_conductor_terminals(
255+
self, conductor_shapely: list[Shapely], terminal_specs: list[TerminalSpec]
256+
) -> list[VoltageSets]:
257+
"""Given a list of terminal specs and the isolated conductors in the mode plane,
258+
returns the indices of conductors that make up the terminal specifications."""
259+
terminals = []
260+
for term_spec in terminal_specs:
261+
all_plus_indices = set()
262+
all_minus_indices = set()
263+
for plus_terminal in term_spec.plus_terminals:
264+
plus = Point(*plus_terminal)
265+
plus_indices = [
266+
i for i, geom in enumerate(conductor_shapely) if geom.contains(plus)
267+
]
268+
assert len(plus_indices) == 1
269+
all_plus_indices.update(plus_indices)
270+
for minus_terminal in term_spec.minus_terminals:
271+
minus = Point(*minus_terminal)
272+
minus_indices = [
273+
i for i, geom in enumerate(conductor_shapely) if geom.contains(minus)
274+
]
275+
assert len(minus_indices) == 1
276+
all_minus_indices.update(minus_indices)
277+
terminals.append((all_plus_indices, all_minus_indices))
278+
return terminals
279+
280+
def _validate_conductor_voltage_configurations(
281+
self, conductor_voltage_sets: list[VoltageSets]
282+
) -> None:
283+
# Check that a conductor index only belongs in either plus or minus sets
284+
for voltage_set in conductor_voltage_sets:
285+
if not voltage_set[0].isdisjoint(voltage_set[1]):
286+
raise ValueError(
287+
"A conductor cannot be assigned to both a positive and negative voltage."
288+
)
289+
290+
# Check that only unique polarity configurations exist
291+
# Two configurations are considered the same if one is the polarity-reversed version of the other
292+
unique_terminal_configuration = set()
293+
294+
for voltage_set in conductor_voltage_sets:
295+
pos, neg = voltage_set
296+
297+
# Create a normalized representation that treats (pos, neg) and (neg, pos) as equivalent
298+
# Using frozenset of frozensets ensures order-independence
299+
terminal_configuration = frozenset([frozenset(pos), frozenset(neg)])
300+
301+
if terminal_configuration in unique_terminal_configuration:
302+
raise ValueError(
303+
"Duplicate voltage configuration detected. "
304+
"Each unique pair of conductor sets (including polarity-reversed pairs) can only appear once."
305+
)
306+
307+
unique_terminal_configuration.add(terminal_configuration)
308+
249309
def _check_box_intersects_with_conductors(
250310
self, shapely_list: list[Shapely], bounding_box: Box
251311
) -> bool:

tidy3d/components/mode/mode_solver.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,10 +1407,22 @@ def _make_path_integrals(
14071407
)
14081408
return make_path_integrals(self.mode_spec)
14091409

1410+
def _post_process_modes_with_terminal_specs(
1411+
self,
1412+
mode_solver_data: MicrowaveModeSolverData,
1413+
) -> MicrowaveModeSolverData:
1414+
"""Select, sort and post process modes to match terminal specifications."""
1415+
raise SetupError("Terminal-based mode setup is not available for the local mode solver.")
1416+
14101417
def _add_microwave_data(
14111418
self, mode_solver_data: MicrowaveModeSolverData
14121419
) -> MicrowaveModeSolverData:
14131420
"""Calculate and add microwave data to ``mode_solver_data`` which uses the path specifications."""
1421+
1422+
# Check if terminal specifications are present and will be used to drive the mode ordering and selection
1423+
if self.mode_spec.terminal_specs is not None:
1424+
mode_solver_data = self._post_process_modes_with_terminal_specs(mode_solver_data)
1425+
14141426
voltage_integrals, current_integrals = self._make_path_integrals()
14151427
# Need to operate on the full symmetry expanded fields
14161428
mode_solver_data_expanded = mode_solver_data.symmetry_expanded_copy
@@ -1848,7 +1860,7 @@ def _grid_correction(
18481860
mode_spec: ModeSpec,
18491861
n_complex: ModeIndexDataArray,
18501862
direction: Direction,
1851-
) -> [FreqModeDataArray, FreqModeDataArray]:
1863+
) -> tuple[FreqModeDataArray, FreqModeDataArray]:
18521864
"""Correct the fields due to propagation on the grid.
18531865
18541866
Return a copy of the :class:`.ModeSolverData` with the fields renormalized to account
@@ -1864,7 +1876,7 @@ def _grid_correction(
18641876
18651877
Returns
18661878
-------
1867-
:class:`.ModeSolverData`
1879+
tuple[:class:`.FreqModeDataArray`, :class:`.FreqModeDataArray`]
18681880
Copy of the data with renormalized fields.
18691881
"""
18701882
normal_axis = plane.size.index(0.0)

0 commit comments

Comments
 (0)