From a26413b962d3d42dd46a9d3e10188698f198ddeb Mon Sep 17 00:00:00 2001 From: dmarek Date: Sun, 26 Oct 2025 21:48:02 -0400 Subject: [PATCH] feat(rf): add voltage-based selection of modes to the ModeSolver --- schemas/EMESimulation.json | 85 +++ schemas/ModeSimulation.json | 85 +++ schemas/Simulation.json | 85 +++ schemas/TerminalComponentModeler.json | 85 +++ tests/test_components/microwave/__init__.py | 1 + .../{ => microwave}/test_microwave.py | 584 +++++------------- .../microwave/test_mode_plane_analyzer.py | 514 +++++++++++++++ tests/test_components/microwave/utils.py | 384 ++++++++++++ tests/test_components/test_simulation.py | 4 +- tidy3d/__init__.py | 5 +- .../components/microwave/data/monitor_data.py | 26 + tidy3d/components/microwave/mode_spec.py | 120 +++- .../path_integrals/mode_plane_analyzer.py | 349 +++++++++-- tidy3d/components/mode/mode_solver.py | 59 +- tidy3d/components/simulation.py | 36 +- 15 files changed, 1898 insertions(+), 524 deletions(-) create mode 100644 tests/test_components/microwave/__init__.py rename tests/test_components/{ => microwave}/test_microwave.py (79%) create mode 100644 tests/test_components/microwave/test_mode_plane_analyzer.py create mode 100644 tests/test_components/microwave/utils.py diff --git a/schemas/EMESimulation.json b/schemas/EMESimulation.json index a43d923d8c..216e11c500 100644 --- a/schemas/EMESimulation.json +++ b/schemas/EMESimulation.json @@ -8000,6 +8000,12 @@ ], "type": "string" }, + "quasi_tem_threshold": { + "default": 0.97, + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, "sort_spec": { "allOf": [ { @@ -8022,6 +8028,12 @@ "exclusiveMinimum": 0, "type": "number" }, + "terminal_specs": { + "items": { + "$ref": "#/definitions/TerminalSpec" + }, + "type": "array" + }, "track_freq": { "enum": [ "central", @@ -11615,6 +11627,79 @@ }, "type": "object" }, + "TerminalSpec": { + "additionalProperties": false, + "properties": { + "attrs": { + "default": {}, + "type": "object" + }, + "minus_terminals": { + "items": { + "anyOf": [ + { + "items": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "ArrayLike" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + "plus_terminals": { + "items": { + "anyOf": [ + { + "items": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "ArrayLike" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + "type": { + "default": "TerminalSpec", + "enum": [ + "TerminalSpec" + ], + "type": "string" + } + }, + "required": [ + "minus_terminals", + "plus_terminals" + ], + "type": "object" + }, "TetrahedralGridDataset": { "additionalProperties": false, "properties": { diff --git a/schemas/ModeSimulation.json b/schemas/ModeSimulation.json index 7ba56f2805..3f879fba51 100644 --- a/schemas/ModeSimulation.json +++ b/schemas/ModeSimulation.json @@ -7213,6 +7213,12 @@ ], "type": "string" }, + "quasi_tem_threshold": { + "default": 0.97, + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, "sort_spec": { "allOf": [ { @@ -7235,6 +7241,12 @@ "exclusiveMinimum": 0, "type": "number" }, + "terminal_specs": { + "items": { + "$ref": "#/definitions/TerminalSpec" + }, + "type": "array" + }, "track_freq": { "enum": [ "central", @@ -11295,6 +11307,79 @@ }, "type": "object" }, + "TerminalSpec": { + "additionalProperties": false, + "properties": { + "attrs": { + "default": {}, + "type": "object" + }, + "minus_terminals": { + "items": { + "anyOf": [ + { + "items": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "ArrayLike" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + "plus_terminals": { + "items": { + "anyOf": [ + { + "items": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "ArrayLike" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + "type": { + "default": "TerminalSpec", + "enum": [ + "TerminalSpec" + ], + "type": "string" + } + }, + "required": [ + "minus_terminals", + "plus_terminals" + ], + "type": "object" + }, "TetrahedralGridDataset": { "additionalProperties": false, "properties": { diff --git a/schemas/Simulation.json b/schemas/Simulation.json index cfc425c478..9162cb5ba7 100644 --- a/schemas/Simulation.json +++ b/schemas/Simulation.json @@ -11179,6 +11179,12 @@ ], "type": "string" }, + "quasi_tem_threshold": { + "default": 0.97, + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, "sort_spec": { "allOf": [ { @@ -11201,6 +11207,12 @@ "exclusiveMinimum": 0, "type": "number" }, + "terminal_specs": { + "items": { + "$ref": "#/definitions/TerminalSpec" + }, + "type": "array" + }, "track_freq": { "enum": [ "central", @@ -15797,6 +15809,79 @@ ], "type": "object" }, + "TerminalSpec": { + "additionalProperties": false, + "properties": { + "attrs": { + "default": {}, + "type": "object" + }, + "minus_terminals": { + "items": { + "anyOf": [ + { + "items": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "ArrayLike" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + "plus_terminals": { + "items": { + "anyOf": [ + { + "items": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "ArrayLike" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + "type": { + "default": "TerminalSpec", + "enum": [ + "TerminalSpec" + ], + "type": "string" + } + }, + "required": [ + "minus_terminals", + "plus_terminals" + ], + "type": "object" + }, "TetrahedralGridDataset": { "additionalProperties": false, "properties": { diff --git a/schemas/TerminalComponentModeler.json b/schemas/TerminalComponentModeler.json index 01d35aaf24..4a81f0e830 100644 --- a/schemas/TerminalComponentModeler.json +++ b/schemas/TerminalComponentModeler.json @@ -11521,6 +11521,12 @@ ], "type": "string" }, + "quasi_tem_threshold": { + "default": 0.97, + "maximum": 1.0, + "minimum": 0.0, + "type": "number" + }, "sort_spec": { "allOf": [ { @@ -11543,6 +11549,12 @@ "exclusiveMinimum": 0, "type": "number" }, + "terminal_specs": { + "items": { + "$ref": "#/definitions/TerminalSpec" + }, + "type": "array" + }, "track_freq": { "enum": [ "central", @@ -16919,6 +16931,79 @@ ], "type": "object" }, + "TerminalSpec": { + "additionalProperties": false, + "properties": { + "attrs": { + "default": {}, + "type": "object" + }, + "minus_terminals": { + "items": { + "anyOf": [ + { + "items": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "ArrayLike" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + "plus_terminals": { + "items": { + "anyOf": [ + { + "items": [ + { + "type": "number" + }, + { + "type": "number" + } + ], + "maxItems": 2, + "minItems": 2, + "type": "array" + }, + { + "type": "ArrayLike" + }, + { + "type": "string" + } + ] + }, + "type": "array" + }, + "type": { + "default": "TerminalSpec", + "enum": [ + "TerminalSpec" + ], + "type": "string" + } + }, + "required": [ + "minus_terminals", + "plus_terminals" + ], + "type": "object" + }, "TetrahedralGridDataset": { "additionalProperties": false, "properties": { diff --git a/tests/test_components/microwave/__init__.py b/tests/test_components/microwave/__init__.py new file mode 100644 index 0000000000..20c2824249 --- /dev/null +++ b/tests/test_components/microwave/__init__.py @@ -0,0 +1 @@ +"""Microwave-related tests.""" diff --git a/tests/test_components/test_microwave.py b/tests/test_components/microwave/test_microwave.py similarity index 79% rename from tests/test_components/test_microwave.py rename to tests/test_components/microwave/test_microwave.py index 22ca41f79b..31c9442c82 100644 --- a/tests/test_components/test_microwave.py +++ b/tests/test_components/microwave/test_microwave.py @@ -3,14 +3,12 @@ from __future__ import annotations from math import isclose -from typing import Literal import matplotlib.pyplot as plt import numpy as np import pydantic.v1 as pd import pytest import xarray as xr -from shapely import LineString import tidy3d as td from tidy3d.components.data.data_array import FreqModeDataArray @@ -27,15 +25,12 @@ make_path_integrals, make_voltage_integral, ) -from tidy3d.components.microwave.path_integrals.mode_plane_analyzer import ( - ModePlaneAnalyzer, -) -from tidy3d.components.mode.mode_solver import ModeSolver from tidy3d.constants import EPSILON_0 from tidy3d.exceptions import DataError, SetupError, ValidationError -from ..test_data.test_monitor_data import make_directivity_data -from ..utils import AssertLogLevel, get_spatial_coords_dict, run_emulated +from ...test_data.test_monitor_data import make_directivity_data +from ...utils import AssertLogLevel, get_spatial_coords_dict, run_emulated +from .utils import make_mw_sim, make_stripline_mode_solver MAKE_PLOTS = False if MAKE_PLOTS: @@ -200,194 +195,6 @@ def make_coax_field_data(): ) -def make_mw_sim( - use_2D: bool = False, - colocate: bool = False, - transmission_line_type: Literal["microstrip", "cpw", "coax", "stripline"] = "microstrip", - width=3 * mm, - height=1 * mm, - metal_thickness=0.2 * mm, -) -> td.Simulation: - """Helper to create a microwave simulation with a single type of transmission line present.""" - - freq_start = 1e9 - freq_stop = 10e9 - - freq0 = (freq_start + freq_stop) / 2 - fwidth = freq_stop - freq_start - freqs = np.arange(freq_start, freq_stop, 1e9) - - run_time = 60 / fwidth - - length = 40 * mm - sim_width = length - - pec = td.PEC - if use_2D: - metal_thickness = 0.0 - pec = td.PEC2D - - epsr = 4.4 - diel = td.Medium(permittivity=epsr) - - metal_geos = [] - - if transmission_line_type == "microstrip": - substrate = td.Structure( - geometry=td.Box( - center=[0, 0, 0], - size=[td.inf, td.inf, 2 * height], - ), - medium=diel, - ) - metal_geos.append( - td.Box( - center=[0, 0, height + metal_thickness / 2], - size=[td.inf, width, metal_thickness], - ) - ) - elif transmission_line_type == "cpw": - substrate = td.Structure( - geometry=td.Box( - center=[0, 0, 0], - size=[td.inf, td.inf, 2 * height], - ), - medium=diel, - ) - metal_geos.append( - td.Box( - center=[0, 0, height + metal_thickness / 2], - size=[td.inf, width, metal_thickness], - ) - ) - gnd_width = 10 * width - gap = width / 5 - gnd_shift = gnd_width / 2 + gap + width / 2 - metal_geos.append( - td.Box( - center=[0, -gnd_shift, height + metal_thickness / 2], - size=[td.inf, gnd_width, metal_thickness], - ) - ) - metal_geos.append( - td.Box( - center=[0, gnd_shift, height + metal_thickness / 2], - size=[td.inf, gnd_width, metal_thickness], - ) - ) - elif transmission_line_type == "coax": - substrate = td.Structure( - geometry=td.Box( - center=[0, 0, 0], - size=[td.inf, td.inf, 2 * height], - ), - medium=diel, - ) - metal_geos.append( - td.GeometryGroup( - geometries=( - td.ClipOperation( - operation="difference", - geometry_a=td.Cylinder( - axis=0, radius=2 * mm, center=(0, 0, 5 * mm), length=td.inf - ), - geometry_b=td.Cylinder( - axis=0, radius=1.8 * mm, center=(0, 0, 5 * mm), length=td.inf - ), - ), - td.Cylinder(axis=0, radius=0.6 * mm, center=(0, 0, 5 * mm), length=td.inf), - ) - ) - ) - elif transmission_line_type == "stripline": - substrate = td.Structure( - geometry=td.Box( - center=[0, 0, 0], - size=[td.inf, td.inf, 2 * height + metal_thickness], - ), - medium=diel, - ) - metal_geos.append( - td.Box( - center=[0, 0, 0], - size=[td.inf, width, metal_thickness], - ) - ) - gnd_width = 10 * width - metal_geos.append( - td.Box( - center=[0, 0, height + metal_thickness], - size=[td.inf, gnd_width, metal_thickness], - ) - ) - metal_geos.append( - td.Box( - center=[0, 0, -height - metal_thickness], - size=[td.inf, gnd_width, metal_thickness], - ) - ) - else: - raise AssertionError("Incorrect argument") - - metal_structures = [td.Structure(geometry=geo, medium=pec) for geo in metal_geos] - structures = [substrate, *metal_structures] - boundary_spec = td.BoundarySpec( - x=td.Boundary(plus=td.PML(), minus=td.PML()), - y=td.Boundary(plus=td.PML(), minus=td.PML()), - z=td.Boundary(plus=td.PML(), minus=td.PECBoundary()), - ) - - size_sim = [ - length + 2 * width, - sim_width, - 20 * mm + height + metal_thickness, - ] - center_sim = [0, 0, size_sim[2] / 2] - # Slightly different setup for stripline substrate sandwiched between ground planes - if transmission_line_type == "stripline": - center_sim[2] = 0 - boundary_spec = td.BoundarySpec( - x=td.Boundary(plus=td.PML(), minus=td.PML()), - y=td.Boundary(plus=td.PML(), minus=td.PML()), - z=td.Boundary(plus=td.PML(), minus=td.PML()), - ) - size_port = [0, sim_width, size_sim[2]] - center_port = [0, 0, center_sim[2]] - impedance_specs = (td.AutoImpedanceSpec(),) * 4 - mode_spec = td.MicrowaveModeSpec( - num_modes=4, - target_neff=1.8, - impedance_specs=impedance_specs, - ) - - mode_monitor = td.MicrowaveModeMonitor( - center=center_port, size=size_port, freqs=freqs, name="mode_1", colocate=colocate - ) - - gaussian = td.GaussianPulse(freq0=freq0, fwidth=fwidth) - mode_src = td.ModeSource( - center=(-length / 2, 0, center_sim[2]), - size=size_port, - direction="+", - mode_spec=mode_spec, - mode_index=0, - source_time=gaussian, - ) - sim = td.Simulation( - center=center_sim, - size=size_sim, - grid_spec=td.GridSpec.uniform(dl=0.1 * mm), - structures=structures, - sources=[mode_src], - monitors=[mode_monitor], - run_time=run_time, - boundary_spec=boundary_spec, - plot_length_units="mm", - symmetry=(0, 0, 0), - ) - return sim - - def test_inductance_formulas(): """Run the formulas for inductance and compare to precomputed results.""" bar_size = (1000e4, 1e4, 1e4) # case from reference @@ -652,209 +459,6 @@ def test_path_integral_creation(): ) -def test_mode_plane_analyzer_errors(): - """Check that the ModePlaneAnalyzer reports errors properly.""" - - path_spec_gen = ModePlaneAnalyzer(size=(0, 2, 2), field_data_colocated=False) - - # First some quick sanity checks with the helper - test_path = td.Box(center=(0, 0, 0), size=(0, 0.9, 0.1)) - test_shapely = [LineString([(-1, 0), (1, 0)])] - assert path_spec_gen._check_box_intersects_with_conductors(test_shapely, test_path) - - test_path = td.Box(center=(0, 0, 0), size=(0, 2.1, 0.1)) - test_shapely = [LineString([(-1, 0), (1, 0)])] - assert not path_spec_gen._check_box_intersects_with_conductors(test_shapely, test_path) - - sim = make_mw_sim(False, False, "microstrip") - coax = td.GeometryGroup( - geometries=( - td.ClipOperation( - operation="difference", - geometry_a=td.Cylinder(axis=0, radius=2 * mm, center=(0, 0, 5 * mm), length=td.inf), - geometry_b=td.Cylinder( - axis=0, radius=1.4 * mm, center=(0, 0, 5 * mm), length=td.inf - ), - ), - td.Cylinder(axis=0, radius=1 * mm, center=(0, 0, 5 * mm), length=td.inf), - ) - ) - coax_struct = td.Structure(geometry=coax, medium=td.PEC) - sim = sim.updated_copy(structures=[coax_struct]) - mode_monitor = sim.monitors[0] - modal_plane = td.Box(center=mode_monitor.center, size=mode_monitor.size) - path_spec_gen = ModePlaneAnalyzer( - center=modal_plane.center, - size=modal_plane.size, - field_data_colocated=mode_monitor.colocate, - ) - with pytest.raises(SetupError): - path_spec_gen.get_conductor_bounding_boxes( - sim.structures, - sim.grid, - sim.symmetry, - sim.bounding_box, - ) - - # Error when no conductors intersecting mode plane - path_spec_gen = path_spec_gen.updated_copy(size=(0, 0.1, 0.1), center=(0, 0, 1.5)) - with pytest.raises(SetupError): - path_spec_gen.get_conductor_bounding_boxes( - sim.structures, - sim.grid, - sim.symmetry, - sim.bounding_box, - ) - - -@pytest.mark.parametrize("colocate", [False, True]) -@pytest.mark.parametrize("tline_type", ["microstrip", "cpw", "coax"]) -def test_mode_plane_analyzer_canonical_shapes(colocate, tline_type): - """Test canonical transmission line types to make sure the correct path integrals are generated.""" - sim = make_mw_sim(False, colocate, tline_type) - mode_monitor = sim.monitors[0] - modal_plane = td.Box(center=mode_monitor.center, size=mode_monitor.size) - mode_plane_analyzer = ModePlaneAnalyzer( - center=modal_plane.center, - size=modal_plane.size, - field_data_colocated=mode_monitor.colocate, - ) - bounding_boxes, geos = mode_plane_analyzer.get_conductor_bounding_boxes( - sim.structures, - sim.grid, - sim.symmetry, - sim.bounding_box, - ) - - if tline_type == "coax": - assert len(bounding_boxes) == 2 - for path_spec in bounding_boxes: - assert np.all(np.isclose(path_spec.center, (0, 0, 5 * mm))) - else: - assert len(bounding_boxes) == 1 - assert np.all(np.isclose(bounding_boxes[0].center, (0, 0, 1.1 * mm))) - - -@pytest.mark.parametrize("use_2D", [False, True]) -@pytest.mark.parametrize("symmetry", [(0, 0, 1), (0, 1, 1), (0, 1, 0)]) -def test_mode_plane_analyzer_advanced(use_2D, symmetry): - """The various symmetry permutations as well as with and without 2D structures.""" - sim = make_mw_sim(use_2D, False, "stripline") - - # Add shapes outside the portion considered for the symmetric simulation - bottom_left = td.Structure( - geometry=td.Box( - center=[0, -5 * mm, -5 * mm], - size=[td.inf, 1 * mm, 1 * mm], - ), - medium=td.PEC, - ) - # Add shape only in the symmetric portion - top_right = td.Structure( - geometry=td.Box( - center=[0, 5 * mm, 5 * mm], - size=[td.inf, 1 * mm, 1 * mm], - ), - medium=td.PEC, - ) - - structures = [*list(sim.structures), bottom_left, top_right] - sim = sim.updated_copy(symmetry=symmetry, structures=structures) - mode_monitor = sim.monitors[0] - - modal_plane = td.Box(center=mode_monitor.center, size=mode_monitor.size) - mode_plane_analyzer = ModePlaneAnalyzer( - center=modal_plane.center, - size=modal_plane.size, - field_data_colocated=mode_monitor.colocate, - ) - bounding_boxes, geos = mode_plane_analyzer.get_conductor_bounding_boxes( - sim.structures, - sim.grid, - sim.symmetry, - sim.bounding_box, - ) - - if symmetry[1] == 1 and symmetry[2] == 1: - assert len(bounding_boxes) == 7 - else: - assert len(bounding_boxes) == 5 - - -@pytest.mark.parametrize( - "mode_size", [(1.4 * mm, 1.0 * mm, 0), (1.4 * mm, 2 * mm, 0), (1.4 * mm - 1, 1.0 * mm + 1, 0)] -) -@pytest.mark.parametrize("symmetry", [(0, 0, 0), (0, 1, 0), (1, 1, 0)]) -def test_mode_plane_analyzer_mode_bounds(mode_size, symmetry): - """Test that the the mode plane bounds matches the mode solver grid bounds exactly.""" - - dl = 0.1 * mm - - freq0 = (5e9) / 2 - fwidth = 4e9 - run_time = 60 / fwidth - - boundary_spec = td.BoundarySpec( - x=td.Boundary(plus=td.PECBoundary(), minus=td.PECBoundary()), - y=td.Boundary(plus=td.PECBoundary(), minus=td.PECBoundary()), - z=td.Boundary(plus=td.PECBoundary(), minus=td.PECBoundary()), - ) - impedance_specs = (td.AutoImpedanceSpec(),) * 4 - mode_spec = td.MicrowaveModeSpec( - num_modes=4, - target_neff=1.8, - impedance_specs=impedance_specs, - ) - - metal_box = td.Structure( - geometry=td.Box.from_bounds( - rmin=(0.5 * mm, 0.5 * mm, 0.5 * mm), rmax=(1 * mm - 1 * dl, 0.5 * mm, 0.5 * mm) - ), - medium=td.PEC, - ) - sim = td.Simulation( - center=(0, 0, 0), - size=(2 * mm, 2 * mm, 2 * mm), - grid_spec=td.GridSpec.uniform(dl=dl), - structures=(metal_box,), - run_time=run_time, - boundary_spec=boundary_spec, - plot_length_units="mm", - symmetry=symmetry, - ) - - mode_center = [0, 0, 0] - mode_plane = td.Box(center=mode_center, size=mode_size) - - mms = ModeSolver( - simulation=sim, - plane=mode_plane, - mode_spec=mode_spec, - colocate=True, - freqs=[freq0], - ) - mode_solver_boundaries = mms._solver_grid.boundaries.to_list - mode_plane_analyzer = ModePlaneAnalyzer( - center=mode_center, - size=mode_size, - field_data_colocated=False, - ) - mode_plane_limits = mode_plane_analyzer._get_mode_limits(sim.grid, sim.symmetry) - - for dim in (0, 1): - solver_dim_boundaries = mode_solver_boundaries[dim] - # TODO: Need the second check because the mode solver erroneously adds - # an extra grid cell even when touching the simulation boundary - assert ( - solver_dim_boundaries[0] == mode_plane_limits[0][dim] - or mode_plane_limits[0][dim] == sim.bounds[0][dim] - ) - assert ( - solver_dim_boundaries[-1] == mode_plane_limits[1][dim] - or mode_plane_limits[1][dim] == sim.bounds[1][dim] - ) - - def test_impedance_spec_validation(): """Check that the various allowed methods for supplying path specifications are validated.""" @@ -1085,29 +689,14 @@ def test_mode_solver_with_microwave_mode_spec(): width = 1.0 * mm height = 0.5 * mm metal_thickness = 0.1 * mm + dl = 0.05 * mm + num_modes = 3 - stripline_sim = make_mw_sim( - transmission_line_type="stripline", + mms, stripline_sim = make_stripline_mode_solver( width=width, height=height, metal_thickness=metal_thickness, - ) - dl = 0.05 * mm - stripline_sim = stripline_sim.updated_copy(grid_spec=td.GridSpec.uniform(dl=dl)) - - plane = td.Box(center=(0, 0, 0), size=(0, 10 * width, 2 * height + metal_thickness)) - num_modes = 3 - impedance_specs = td.AutoImpedanceSpec() - mode_spec = td.MicrowaveModeSpec( - num_modes=num_modes, - target_neff=2.2, - impedance_specs=impedance_specs, - ) - mms = ModeSolver( - simulation=stripline_sim, - plane=plane, - mode_spec=mode_spec, - colocate=False, + dl=dl, freqs=[1e9, 5e9, 10e9], ) @@ -1145,6 +734,12 @@ def test_mode_solver_with_microwave_mode_spec(): np.isclose(mms_data.transmission_line_data.Z0.real.sel(mode_index=0), 28.6, rtol=0.2) ) + # Test that the first mode is identified as a transmission line mode (quasi-TEM) + assert mms_data._is_transmission_line_mode(0), "First mode should be quasi-TEM for stripline" + assert not mms_data._is_transmission_line_mode(1), ( + "Second mode will not be a transmission line mode" + ) + # Make sure a single spec can be used microwave_spec_custom = td.MicrowaveModeSpec( num_modes=num_modes, target_neff=2.2, impedance_specs=custom_spec @@ -1162,22 +757,21 @@ def test_mode_solver_with_microwave_group_index(): width = 1.0 * mm height = 0.5 * mm metal_thickness = 0.1 * mm - - stripline_sim = make_mw_sim( - transmission_line_type="stripline", - width=width, - height=height, - metal_thickness=metal_thickness, - ) dl = 0.05 * mm - stripline_sim = stripline_sim.updated_copy(grid_spec=td.GridSpec.uniform(dl=dl)) - - plane = td.Box(center=(0, 0, 0), size=(0, 10 * width, 2 * height + metal_thickness)) num_modes = 1 # Define original frequencies that we want in the final result original_freqs = [1e9, 5e9, 10e9] + # Get the basic mode solver setup + mms, stripline_sim = make_stripline_mode_solver( + width=width, + height=height, + metal_thickness=metal_thickness, + dl=dl, + freqs=original_freqs, + ) + # Create custom impedance spec (AutoImpedanceSpec won't work with local mode solver) custom_spec = td.CustomImpedanceSpec( voltage_spec=None, @@ -1194,13 +788,8 @@ def test_mode_solver_with_microwave_group_index(): group_index_step=True, # This will expand frequencies to triplets ) - mms = ModeSolver( - simulation=stripline_sim, - plane=plane, - mode_spec=mode_spec, - colocate=False, - freqs=original_freqs, - ) + # Update mode solver with new spec + mms = mms.updated_copy(mode_spec=mode_spec) # Get the mode solver data mms_data: td.MicrowaveModeSolverData = mms.data @@ -1876,3 +1465,128 @@ def test_RF_license_suppression(): with AssertLogLevel(None): mode_spec = td.MicrowaveModeSpec._default_without_license_warning() td.config.microwave.suppress_rf_license_warning = original_setting + + +def test_mode_solver_validates_terminal_specs(): + """Test that ModeSolver validates terminal specifications during initialization.""" + width = 1.0 * mm + height = 0.5 * mm + metal_thickness = 0.1 * mm + dl = 0.05 * mm + + # Create a stripline mode solver setup + mms, stripline_sim = make_stripline_mode_solver( + width=width, + height=height, + metal_thickness=metal_thickness, + dl=dl, + freqs=[1e9], + ) + + # Valid terminal spec: point inside the conductor + valid_terminal_spec = td.TerminalSpec(plus_terminals=((0.0, 0.0),), minus_terminals=()) + mode_spec_with_terminals = td.MicrowaveModeSpec( + num_modes=1, + target_neff=2.2, + terminal_specs=(valid_terminal_spec,), + impedance_specs=td.CustomImpedanceSpec( + voltage_spec=None, + current_spec=td.AxisAlignedCurrentIntegralSpec( + size=(0, width + dl, metal_thickness + dl), sign="+" + ), + ), + ) + + # Should not raise - valid terminal spec + mms_valid = mms.updated_copy(mode_spec=mode_spec_with_terminals) + assert mms_valid.mode_spec.terminal_specs is not None + # Should raise SetupError since running with TerminalSpec is not available with the local mode solver + with pytest.raises(SetupError, match="Terminal-based mode setup is not available"): + mms_valid.data + + # Invalid terminal spec: point far from any conductor + invalid_terminal_spec = td.TerminalSpec( + plus_terminals=((100 * mm, 100 * mm),), minus_terminals=() + ) + mode_spec_invalid = td.MicrowaveModeSpec( + num_modes=1, + target_neff=2.2, + terminal_specs=(invalid_terminal_spec,), + impedance_specs=td.CustomImpedanceSpec( + voltage_spec=None, + current_spec=td.AxisAlignedCurrentIntegralSpec( + size=(0, width + dl, metal_thickness + dl), sign="+" + ), + ), + ) + + # Should raise SetupError due to invalid terminal spec + with pytest.raises(SetupError, match="'TerminalSpec' was not setup correctly"): + mms.updated_copy(mode_spec=mode_spec_invalid) + + +# def test_mode_solver_validates_conductor_bounding_boxes(): +# """Test that ModeSolver validates conductor bounding boxes for auto current spec.""" +# width = 1.0 * mm +# height = 0.5 * mm +# metal_thickness = 0.1 * mm +# dl = 0.05 * mm + +# # Create a stripline mode solver setup +# mms, stripline_sim = make_stripline_mode_solver( +# width=width, +# height=height, +# metal_thickness=metal_thickness, +# dl=dl, +# freqs=[1e9], +# ) + +# # Use auto impedance spec which requires conductor bounding boxes +# auto_mode_spec = td.MicrowaveModeSpec( +# num_modes=1, target_neff=2.2, impedance_specs=td.AutoImpedanceSpec() +# ) + +# # This should raise during initialization because auto spec requires running mode solver +# # but validation should still check that conductors can be identified +# with pytest.raises(SetupError, match="Auto path specification is not available"): +# mms_auto = mms.updated_copy(mode_spec=auto_mode_spec) +# _ = mms_auto.data # Try to access data, which triggers auto spec failure + + +# def test_mode_solver_terminal_spec_conflict_detection(): +# """Test that ModeSolver detects conflicting terminal specifications.""" +# from tidy3d.components.mode.mode_solver import ModeSolver + +# # Create a coupled microstrip setup with two conductors +# from .utils import make_coupled_microstrip_sim + +# sim = make_coupled_microstrip_sim() + +# # Create terminal specs with conflicting polarities +# # Same conductors in reversed polarity - should be detected as duplicate +# strip_left_center = sim.structures[1].geometry.center[1:3] +# strip_right_center = sim.structures[2].geometry.center[1:3] + +# terminal_spec_1 = td.TerminalSpec( +# plus_terminals=(strip_left_center,), minus_terminals=(strip_right_center,) +# ) +# terminal_spec_2 = td.TerminalSpec( +# plus_terminals=(strip_right_center,), minus_terminals=(strip_left_center,) +# ) + +# conflicting_mode_spec = td.MicrowaveModeSpec( +# num_modes=2, +# terminal_specs=(terminal_spec_1, terminal_spec_2), +# impedance_specs=td.AutoImpedanceSpec(), +# ) + +# mode_monitor = sim.monitors[0] + +# # Should raise SetupError due to duplicate/conflicting terminal specs +# with pytest.raises(SetupError, match="'TerminalSpec' was not setup correctly"): +# mms = ModeSolver( +# simulation=sim, +# plane=td.Box(center=mode_monitor.center, size=mode_monitor.size), +# mode_spec=conflicting_mode_spec, +# freqs=[5e9], +# ) diff --git a/tests/test_components/microwave/test_mode_plane_analyzer.py b/tests/test_components/microwave/test_mode_plane_analyzer.py new file mode 100644 index 0000000000..fccef6b066 --- /dev/null +++ b/tests/test_components/microwave/test_mode_plane_analyzer.py @@ -0,0 +1,514 @@ +"""Tests for ModePlaneAnalyzer and terminal specification features.""" + +from __future__ import annotations + +import numpy as np +import pydantic.v1 as pd +import pytest +from shapely import LineString + +import tidy3d as td +from tidy3d.components.microwave.path_integrals.mode_plane_analyzer import ModePlaneAnalyzer +from tidy3d.components.mode.mode_solver import ModeSolver +from tidy3d.exceptions import SetupError + +from .utils import make_coupled_microstrip_sim, make_minimal_mode_plane_analyzer, make_mw_sim + +# Constants +mm = 1e3 + + +def make_mode_plane_analyzer(sim, monitor): + """Create ModePlaneAnalyzer from simulation and monitor.""" + return ModePlaneAnalyzer( + center=monitor.center, + size=monitor.size, + field_data_colocated=monitor.colocate, + structures=sim.structures, + grid=sim.grid, + symmetry=sim.symmetry, + sim_box=sim.bounding_box, + ) + + +def test_mode_plane_analyzer_errors(): + """Check that the ModePlaneAnalyzer reports errors properly.""" + + mode_plane_analyzer = make_minimal_mode_plane_analyzer() + + # First some quick sanity checks with the helper + test_path = td.Box(center=(0, 0, 0), size=(0, 0.9, 0.1)) + test_shapely = [LineString([(-1, 0), (1, 0)])] + assert mode_plane_analyzer._check_box_intersects_with_conductors(test_shapely, test_path) + + test_path = td.Box(center=(0, 0, 0), size=(0, 2.1, 0.1)) + test_shapely = [LineString([(-1, 0), (1, 0)])] + assert not mode_plane_analyzer._check_box_intersects_with_conductors(test_shapely, test_path) + + sim = make_mw_sim(False, False, "microstrip") + coax = td.GeometryGroup( + geometries=( + td.ClipOperation( + operation="difference", + geometry_a=td.Cylinder(axis=0, radius=2 * mm, center=(0, 0, 5 * mm), length=td.inf), + geometry_b=td.Cylinder( + axis=0, radius=1.4 * mm, center=(0, 0, 5 * mm), length=td.inf + ), + ), + td.Cylinder(axis=0, radius=1 * mm, center=(0, 0, 5 * mm), length=td.inf), + ) + ) + coax_struct = td.Structure(geometry=coax, medium=td.PEC) + sim = sim.updated_copy(structures=[coax_struct]) + mode_monitor = sim.monitors[0] + mode_plane_analyzer = make_mode_plane_analyzer(sim, mode_monitor) + + with pytest.raises(SetupError): + _ = mode_plane_analyzer.conductor_bounding_boxes + + # Error when no conductors intersecting mode plane + mode_plane_analyzer = mode_plane_analyzer.updated_copy(size=(0, 0.1, 0.1), center=(0, 0, 1.5)) + with pytest.raises(SetupError): + _ = mode_plane_analyzer.conductor_shapes + + +@pytest.mark.parametrize("colocate", [False, True]) +@pytest.mark.parametrize("tline_type", ["microstrip", "cpw", "coax"]) +def test_mode_plane_analyzer_canonical_shapes(colocate, tline_type): + """Test canonical transmission line types to make sure the correct path integrals are generated.""" + sim = make_mw_sim(False, colocate, tline_type) + mode_monitor = sim.monitors[0] + mode_plane_analyzer = make_mode_plane_analyzer(sim, mode_monitor) + bounding_boxes = mode_plane_analyzer.conductor_bounding_boxes + geos = mode_plane_analyzer.conductor_shapes + + if tline_type == "coax": + assert mode_plane_analyzer.num_conductors == 2 + assert len(bounding_boxes) == 2 + for path_spec in bounding_boxes: + assert np.all(np.isclose(path_spec.center, (0, 0, 5 * mm))) + else: + assert mode_plane_analyzer.num_conductors == 1 + assert len(bounding_boxes) == 1 + assert np.all(np.isclose(bounding_boxes[0].center, (0, 0, 1.1 * mm))) + + +@pytest.mark.parametrize("use_2D", [False, True]) +@pytest.mark.parametrize("symmetry", [(0, 0, 1), (0, 1, 1), (0, 1, 0)]) +def test_mode_plane_analyzer_advanced(use_2D, symmetry): + """The various symmetry permutations as well as with and without 2D structures.""" + sim = make_mw_sim(use_2D, False, "stripline") + + # Add shapes outside the portion considered for the symmetric simulation + bottom_left = td.Structure( + geometry=td.Box( + center=[0, -5 * mm, -5 * mm], + size=[td.inf, 1 * mm, 1 * mm], + ), + medium=td.PEC, + ) + # Add shape only in the symmetric portion + top_right = td.Structure( + geometry=td.Box( + center=[0, 5 * mm, 5 * mm], + size=[td.inf, 1 * mm, 1 * mm], + ), + medium=td.PEC, + ) + + structures = [*list(sim.structures), bottom_left, top_right] + sim = sim.updated_copy(symmetry=symmetry, structures=structures) + mode_monitor = sim.monitors[0] + mode_plane_analyzer = make_mode_plane_analyzer(sim, mode_monitor) + bounding_boxes = mode_plane_analyzer.conductor_bounding_boxes + geos = mode_plane_analyzer.conductor_shapes + + if symmetry[1] == 1 and symmetry[2] == 1: + assert len(bounding_boxes) == 7 + else: + assert len(bounding_boxes) == 5 + + +@pytest.mark.parametrize( + "mode_size", [(1.4 * mm, 1.0 * mm, 0), (1.4 * mm, 2 * mm, 0), (1.4 * mm - 1, 1.0 * mm + 1, 0)] +) +@pytest.mark.parametrize("symmetry", [(0, 0, 0), (0, 1, 0), (1, 1, 0)]) +def test_mode_plane_analyzer_mode_bounds(mode_size, symmetry): + """Test that the the mode plane bounds matches the mode solver grid bounds exactly.""" + + dl = 0.1 * mm + + freq0 = (5e9) / 2 + fwidth = 4e9 + run_time = 60 / fwidth + + boundary_spec = td.BoundarySpec( + x=td.Boundary(plus=td.PECBoundary(), minus=td.PECBoundary()), + y=td.Boundary(plus=td.PECBoundary(), minus=td.PECBoundary()), + z=td.Boundary(plus=td.PECBoundary(), minus=td.PECBoundary()), + ) + impedance_specs = (td.AutoImpedanceSpec(),) + mode_spec = td.MicrowaveModeSpec( + num_modes=1, + target_neff=1.8, + impedance_specs=impedance_specs, + ) + + metal_box = td.Structure( + geometry=td.Box.from_bounds( + rmin=(-1 * dl, -1 * dl, -0.5 * mm), rmax=(2 * dl, 2 * dl, 0.5 * mm) + ), + medium=td.PEC, + ) + sim = td.Simulation( + center=(0, 0, 0), + size=(2 * mm, 2 * mm, 2 * mm), + grid_spec=td.GridSpec.uniform(dl=dl), + structures=(metal_box,), + run_time=run_time, + boundary_spec=boundary_spec, + plot_length_units="mm", + symmetry=symmetry, + ) + + mode_center = [0, 0, 0] + mode_plane = td.Box(center=mode_center, size=mode_size) + + mms = ModeSolver( + simulation=sim, + plane=mode_plane, + mode_spec=mode_spec, + colocate=False, + freqs=[freq0], + ) + mode_solver_boundaries = mms._solver_grid.boundaries.to_list + mode_plane_analyzer = ModePlaneAnalyzer( + center=mode_center, + size=mode_size, + field_data_colocated=False, + structures=sim.structures, + grid=sim.grid, + symmetry=sim.symmetry, + sim_box=sim.bounding_box, + ) + mode_plane_limits = mode_plane_analyzer.mode_limits + + for dim in (0, 1): + solver_dim_boundaries = mode_solver_boundaries[dim] + # TODO: Need the second check because the mode solver erroneously adds + # an extra grid cell even when touching the simulation boundary + assert ( + solver_dim_boundaries[0] == mode_plane_limits[0][dim] + or mode_plane_limits[0][dim] == sim.bounds[0][dim] + ) + assert ( + solver_dim_boundaries[-1] == mode_plane_limits[1][dim] + or mode_plane_limits[1][dim] == sim.bounds[1][dim] + ) + + +def test_validate_conductor_voltage_configurations(): + """Test validation of conductor voltage configurations in ModePlaneAnalyzer. + + Tests common user errors: + 1. Valid configuration + 2. Conductor assigned to both positive and negative terminals + 3. Polarity-reversed duplicates + """ + analyzer = make_minimal_mode_plane_analyzer() + + # Valid: multiple terminal configurations with different conductors + valid_config = [ + ({0}, {1}), # Terminal 1: conductor 0 (+), conductor 1 (-) + ({2}, {3}), # Terminal 2: conductor 2 (+), conductor 3 (-) + ] + analyzer._validate_conductor_voltage_configurations(valid_config) # Should not raise + + # ERROR: Conductor in both positive and negative sets + error_both_polarities = [ + ({0, 1}, {1, 2}) # Conductor 1 is in BOTH positive and negative! + ] + with pytest.raises(SetupError, match="cannot be assigned to both a positive and negative"): + analyzer._validate_conductor_voltage_configurations(error_both_polarities) + + # ERROR: Polarity-reversed duplicates (most common user error!) + # ({0}, {1}) and ({1}, {0}) represent the same terminal with reversed polarity + error_polarity_reversed = [ + ({0}, {1}), # Terminal 1: conductor 0 (+), conductor 1 (-) + ({1}, {0}), # Terminal 2: conductor 1 (+), conductor 0 (-) <- SAME TERMINAL! + ] + with pytest.raises(SetupError, match="Duplicate voltage configuration"): + analyzer._validate_conductor_voltage_configurations(error_polarity_reversed) + + +def test_validate_empty_terminal_sets(): + """Handle empty plus/minus sets.""" + analyzer = make_minimal_mode_plane_analyzer() + + # Valid: One set can be empty (for common mode or single-ended) + valid_empty_minus = [({0, 1}, set())] # Common mode: multiple plus, no minus + analyzer._validate_conductor_voltage_configurations(valid_empty_minus) # Should not raise + + # This is also valid: no plus, only minus (though unusual) + valid_empty_plus = [(set(), {0, 1})] + analyzer._validate_conductor_voltage_configurations(valid_empty_plus) # Should not raise + + +def test_validate_exact_duplicates(): + """Not just polarity-reversed, but exact copies.""" + analyzer = make_minimal_mode_plane_analyzer() + + # ERROR: Exact duplicate configurations + exact_duplicates = [ + ({0}, {1}), # First terminal spec + ({0}, {1}), # Exact duplicate + ] + with pytest.raises(SetupError, match="Duplicate voltage configuration"): + analyzer._validate_conductor_voltage_configurations(exact_duplicates) + + +def test_validate_complex_multi_conductor(): + """4+ conductor scenarios.""" + analyzer = make_minimal_mode_plane_analyzer() + + # Valid: Complex multi-conductor setup + valid_complex = [ + ({0, 1}, {2}), # Terminal 1: conductors 0,1 (+), conductor 2 (-) + ({3}, {4, 5}), # Terminal 2: conductor 3 (+), conductors 4,5 (-) + ] + analyzer._validate_conductor_voltage_configurations(valid_complex) # Should not raise + + +def test_terminal_spec_valid_inputs(): + """Test TerminalSpec validation with valid inputs for plus_terminals and minus_terminals.""" + # Test Case 1.1: Coordinate2D tuples (single coordinate) + spec = td.TerminalSpec(plus_terminals=((1.0, 0.5),), minus_terminals=((-1.0, 0.5),)) + assert spec is not None + assert len(spec.plus_terminals) == 1 + assert spec.plus_terminals[0] == (1.0, 0.5) + + # Test Case 1.2: Multiple Coordinate2D tuples + spec = td.TerminalSpec(plus_terminals=((1.0, 0.5), (2.0, 0.5)), minus_terminals=()) + assert len(spec.plus_terminals) == 2 + + # Test Case 1.3: String identifiers + spec = td.TerminalSpec( + plus_terminals=("conductor_1", "conductor_2"), minus_terminals=("ground",) + ) + assert spec.plus_terminals[0] == "conductor_1" + assert spec.minus_terminals[0] == "ground" + + # Test Case 1.4: ArrayFloat2D with 2 points (linestring) + spec = td.TerminalSpec(plus_terminals=(np.array([[0.0, 0.0], [1.0, 1.0]]),), minus_terminals=()) + assert isinstance(spec.plus_terminals[0], np.ndarray) + assert spec.plus_terminals[0].shape == (2, 2) + + # Test Case 1.5: ArrayFloat2D with 3+ points (polygon) + spec = td.TerminalSpec( + plus_terminals=(np.array([[0, 0], [1, 0], [1, 1], [0, 1]]),), minus_terminals=() + ) + assert isinstance(spec.plus_terminals[0], np.ndarray) + assert spec.plus_terminals[0].shape == (4, 2) + + # Test Case 1.6: Lists converted to numpy by ArrayFloat2D + spec = td.TerminalSpec(plus_terminals=([[0.0, 0.0], [1.0, 1.0]],), minus_terminals=()) + # List should be converted to numpy array + assert isinstance(spec.plus_terminals[0], np.ndarray) + assert spec.plus_terminals[0].shape == (2, 2) + + # Test Case 1.7: Mixed types in same spec + spec = td.TerminalSpec( + plus_terminals=( + (1.0, 0.5), # Coordinate2D + "conductor_2", # str + np.array([[0, 0], [1, 1]]), # ArrayFloat2D + ), + minus_terminals=(), + ) + assert isinstance(spec.plus_terminals[0], tuple) + assert isinstance(spec.plus_terminals[1], str) + assert isinstance(spec.plus_terminals[2], np.ndarray) + + # Test Case 1.8: Empty minus_terminals (valid for common mode) + spec = td.TerminalSpec(plus_terminals=((1.0, 0.5),), minus_terminals=()) + assert len(spec.minus_terminals) == 0 + + # Test Case 1.9: Exactly 3 points (minimum for polygon - triangle) + spec = td.TerminalSpec( + plus_terminals=(np.array([[0, 0], [1, 0], [0.5, 1]]),), minus_terminals=() + ) + assert spec.plus_terminals[0].shape == (3, 2) + + # Test Case 1.10: Exactly 2 points + spec = td.TerminalSpec(plus_terminals=(np.array([[0.0, 0.0], [1.0, 0.0]]),), minus_terminals=()) + assert spec.plus_terminals[0].shape == (2, 2) + + # Test Case 1.11: 1 point but using ArrayLike + spec = td.TerminalSpec(plus_terminals=(np.array([[1.0, 0.0]]),), minus_terminals=()) + assert spec.plus_terminals[0].shape == (1, 2) + + +def test_terminal_spec_invalid_inputs(): + """Test TerminalSpec validation rejects invalid inputs.""" + # Test Case 2.1: Invalid array shape (not Nx2) + with pytest.raises(pd.ValidationError): + td.TerminalSpec( + plus_terminals=(np.array([[0, 0, 0], [1, 1, 1]]),), # 3 columns instead of 2 + minus_terminals=(), + ) + + # Test Case 2.2: Invalid polygon (self-intersecting bowtie) + with pytest.raises(pd.ValidationError, match="valid polygon"): + td.TerminalSpec( + plus_terminals=( + np.array([[0, 0], [1, 1], [1, 0], [0, 1]]), + ), # Figure-8 self-intersection + minus_terminals=(), + ) + + # Test Case 2.3: 1D array instead of 2D + with pytest.raises(pd.ValidationError): + td.TerminalSpec( + plus_terminals=(np.array([0, 1, 2]),), # 1D array + minus_terminals=(), + ) + + # Test Case 2.4: Wrong coordinate tuple length (3 elements instead of 2) + with pytest.raises(pd.ValidationError): + td.TerminalSpec( + plus_terminals=((1.0, 0.5, 2.0),), # 3 elements + minus_terminals=(), + ) + + +def test_terminal_spec_microstrip(): + """TerminalSpec → conversion → finding → validation (microstrip).""" + sim = make_mw_sim(transmission_line_type="microstrip") + mode_monitor = sim.monitors[0] + analyzer = make_mode_plane_analyzer(sim, mode_monitor) + + # Define terminal spec: signal line as positive terminal + terminal_specs = [td.TerminalSpec(plus_terminals=((0.0, 1.1 * mm),), minus_terminals=())] + voltage_sets = analyzer._identify_conductor_voltage_sets(tuple(terminal_specs)) + + # Validate configuration + analyzer._validate_conductor_voltage_configurations(voltage_sets) # Should not raise + + # Verify results + assert len(voltage_sets) == 1 + assert len(voltage_sets[0][0]) == 1 # One plus conductor (signal) + assert len(voltage_sets[0][1]) == 0 # No minus conductor (single-ended) + + +def test_terminal_spec_cpw(): + """Test with CPW (3 conductors, but 2 touching PEC boundary).""" + sim = make_mw_sim(transmission_line_type="cpw") + mode_monitor = sim.monitors[0] + analyzer = make_mode_plane_analyzer(sim, mode_monitor) + + # CPW: signal line positive, ground planes can be reference + terminal_specs = [td.TerminalSpec(plus_terminals=((0.0, 1.1 * mm),), minus_terminals=())] + + voltage_sets = analyzer._identify_conductor_voltage_sets(tuple(terminal_specs)) + analyzer._validate_conductor_voltage_configurations(voltage_sets) + + assert len(voltage_sets) == 1 + + +@pytest.mark.parametrize("terminal_spec_type", ["Point", "Line", "Polygon"]) +def test_terminal_spec_coupled_microstrip(terminal_spec_type): + """Test with coupled microstrip (2 signal lines).""" + sim = make_coupled_microstrip_sim() + mode_monitor = sim.monitors[0] + analyzer = make_mode_plane_analyzer(sim, mode_monitor) + + strip_left = sim.structures[1] + strip_right = sim.structures[2] + # Define terminal specifications + # Differential mode: strip_left is +, strip_right is - + # Common mode: both strips are + + if terminal_spec_type == "Point": + conductor_1_id = np.asarray( + [[strip_left.geometry.center[1], strip_left.geometry.center[2]]] + ) + conductor_2_id = np.asarray( + [[strip_right.geometry.center[1], strip_right.geometry.center[2]]] + ) + elif terminal_spec_type == "Line": + conductor_1_id = [strip_left.geometry.bounds[0][1:3], strip_left.geometry.bounds[1][1:3]] + conductor_2_id = [strip_right.geometry.bounds[0][1:3], strip_right.geometry.bounds[1][1:3]] + elif terminal_spec_type == "Polygon": + conductor_1_id = [ + strip_left.geometry.bounds[0][1:3], + strip_left.geometry.bounds[1][1:3], + sim.center[1:3], + ] + conductor_2_id = [ + strip_right.geometry.bounds[0][1:3], + strip_right.geometry.bounds[1][1:3], + sim.center[1:3], + ] + + diff_spec = td.TerminalSpec(plus_terminals=(conductor_1_id,), minus_terminals=(conductor_2_id,)) + common_spec = td.TerminalSpec( + plus_terminals=(conductor_1_id, conductor_2_id), minus_terminals=() + ) + terminal_specs = [diff_spec, common_spec] + + voltage_sets = analyzer._identify_conductor_voltage_sets(tuple(terminal_specs)) + analyzer._validate_conductor_voltage_configurations(voltage_sets) + + assert len(voltage_sets) == 2 + assert voltage_sets[0][0] == {0} # One plus conductor + assert voltage_sets[0][1] == {1} # One minus conductor + assert voltage_sets[1][0] == {0, 1} # Both plus conductors + assert voltage_sets[1][1] == set() # No conductors + + +def test_terminal_spec_with_structure_names(): + """Use structure names instead of coordinates.""" + sim = make_coupled_microstrip_sim() + mode_monitor = sim.monitors[0] + analyzer = make_mode_plane_analyzer(sim, mode_monitor) + + # Use structure names + terminal_specs = [td.TerminalSpec(plus_terminals=("signal_1",), minus_terminals=("signal_2",))] + + voltage_sets = analyzer._identify_conductor_voltage_sets(tuple(terminal_specs)) + analyzer._validate_conductor_voltage_configurations(voltage_sets) + + assert len(voltage_sets) == 1 + assert len(voltage_sets[0][0]) == 1 # signal_1 + assert len(voltage_sets[0][1]) == 1 # signal_2 + + +def test_terminal_spec_error_messages_helpful(): + """Verify helpful error messages for common mistakes.""" + sim = make_coupled_microstrip_sim() + mode_monitor = sim.monitors[0] + analyzer = make_mode_plane_analyzer(sim, mode_monitor) + + # Error 1: Structure name not found + terminal_specs = [td.TerminalSpec(plus_terminals=("nonexistent",), minus_terminals=())] + with pytest.raises(SetupError, match="No structure found with name 'nonexistent'"): + analyzer._convert_terminal_specifications_to_candidate_geometry( + sim.structures, terminal_specs + ) + + # Error 2: Terminal doesn't intersect any conductor + terminal_specs = [td.TerminalSpec(plus_terminals=((100 * mm, 100 * mm),), minus_terminals=())] + with pytest.raises(SetupError, match="No conductor found intersecting"): + analyzer._identify_conductor_voltage_sets(tuple(terminal_specs)) + + # Error 3: Terminal spec accidentally touches 2 conductors + strip_left = sim.structures[1] + strip_right = sim.structures[2] + line_touching_both = [strip_left.geometry.bounds[1][1:3], strip_right.geometry.bounds[0][1:3]] + terminal_specs = [td.TerminalSpec(plus_terminals=(line_touching_both,), minus_terminals=())] + with pytest.raises(SetupError, match="Multiple conductors"): + analyzer._identify_conductor_voltage_sets(tuple(terminal_specs)) + + # Error 4: Conductor in both polarities + voltage_sets = [({0}, {0})] # Same conductor in both plus and minus + with pytest.raises(SetupError, match="cannot be assigned to both"): + analyzer._validate_conductor_voltage_configurations(voltage_sets) diff --git a/tests/test_components/microwave/utils.py b/tests/test_components/microwave/utils.py new file mode 100644 index 0000000000..79eb33c524 --- /dev/null +++ b/tests/test_components/microwave/utils.py @@ -0,0 +1,384 @@ +"""Utility functions for microwave tests. + +This module contains helper functions for creating canonical transmission line simulations +and other test fixtures used across microwave-related tests. +""" + +from __future__ import annotations + +from typing import Literal + +import numpy as np + +import tidy3d as td + +# Constants +mm = 1e3 + + +def make_mw_sim( + use_2D: bool = False, + colocate: bool = False, + transmission_line_type: Literal["microstrip", "cpw", "coax", "stripline"] = "microstrip", + width=3 * mm, + height=1 * mm, + metal_thickness=0.2 * mm, +) -> td.Simulation: + """Helper to create a microwave simulation with a single type of transmission line present.""" + + freq_start = 1e9 + freq_stop = 10e9 + + freq0 = (freq_start + freq_stop) / 2 + fwidth = freq_stop - freq_start + freqs = np.arange(freq_start, freq_stop, 1e9) + + run_time = 60 / fwidth + + length = 40 * mm + sim_width = length + + pec = td.PEC + if use_2D: + metal_thickness = 0.0 + pec = td.PEC2D + + epsr = 4.4 + diel = td.Medium(permittivity=epsr) + + metal_geos = [] + + if transmission_line_type == "microstrip": + substrate = td.Structure( + geometry=td.Box( + center=[0, 0, 0], + size=[td.inf, td.inf, 2 * height], + ), + medium=diel, + ) + metal_geos.append( + td.Box( + center=[0, 0, height + metal_thickness / 2], + size=[td.inf, width, metal_thickness], + ) + ) + elif transmission_line_type == "cpw": + substrate = td.Structure( + geometry=td.Box( + center=[0, 0, 0], + size=[td.inf, td.inf, 2 * height], + ), + medium=diel, + ) + metal_geos.append( + td.Box( + center=[0, 0, height + metal_thickness / 2], + size=[td.inf, width, metal_thickness], + ) + ) + gnd_width = 10 * width + gap = width / 5 + gnd_shift = gnd_width / 2 + gap + width / 2 + metal_geos.append( + td.Box( + center=[0, -gnd_shift, height + metal_thickness / 2], + size=[td.inf, gnd_width, metal_thickness], + ) + ) + metal_geos.append( + td.Box( + center=[0, gnd_shift, height + metal_thickness / 2], + size=[td.inf, gnd_width, metal_thickness], + ) + ) + elif transmission_line_type == "coax": + substrate = td.Structure( + geometry=td.Box( + center=[0, 0, 0], + size=[td.inf, td.inf, 2 * height], + ), + medium=diel, + ) + metal_geos.append( + td.GeometryGroup( + geometries=( + td.ClipOperation( + operation="difference", + geometry_a=td.Cylinder( + axis=0, radius=2 * mm, center=(0, 0, 5 * mm), length=td.inf + ), + geometry_b=td.Cylinder( + axis=0, radius=1.8 * mm, center=(0, 0, 5 * mm), length=td.inf + ), + ), + td.Cylinder(axis=0, radius=0.6 * mm, center=(0, 0, 5 * mm), length=td.inf), + ) + ) + ) + elif transmission_line_type == "stripline": + substrate = td.Structure( + geometry=td.Box( + center=[0, 0, 0], + size=[td.inf, td.inf, 2 * height + metal_thickness], + ), + medium=diel, + ) + metal_geos.append( + td.Box( + center=[0, 0, 0], + size=[td.inf, width, metal_thickness], + ) + ) + gnd_width = 10 * width + metal_geos.append( + td.Box( + center=[0, 0, height + metal_thickness], + size=[td.inf, gnd_width, metal_thickness], + ) + ) + metal_geos.append( + td.Box( + center=[0, 0, -height - metal_thickness], + size=[td.inf, gnd_width, metal_thickness], + ) + ) + else: + raise AssertionError("Incorrect argument") + + metal_structures = [td.Structure(geometry=geo, medium=pec) for geo in metal_geos] + structures = [substrate, *metal_structures] + boundary_spec = td.BoundarySpec( + x=td.Boundary(plus=td.PML(), minus=td.PML()), + y=td.Boundary(plus=td.PML(), minus=td.PML()), + z=td.Boundary(plus=td.PECBoundary(), minus=td.PECBoundary()), + ) + + size_sim = [ + length + 2 * width, + sim_width, + 20 * mm + height + metal_thickness, + ] + center_sim = [0, 0, size_sim[2] / 2] + # Slightly different setup for stripline substrate sandwiched between ground planes + if transmission_line_type == "stripline": + center_sim[2] = 0 + boundary_spec = td.BoundarySpec( + x=td.Boundary(plus=td.PML(), minus=td.PML()), + y=td.Boundary(plus=td.PML(), minus=td.PML()), + z=td.Boundary(plus=td.PML(), minus=td.PML()), + ) + size_port = [0, sim_width, size_sim[2]] + center_port = [0, 0, center_sim[2]] + impedance_specs = (td.AutoImpedanceSpec(),) * 4 + mode_spec = td.MicrowaveModeSpec( + num_modes=4, + target_neff=1.8, + impedance_specs=impedance_specs, + ) + + mode_monitor = td.MicrowaveModeMonitor( + center=center_port, size=size_port, freqs=freqs, name="mode_1", colocate=colocate + ) + + gaussian = td.GaussianPulse(freq0=freq0, fwidth=fwidth) + mode_src = td.ModeSource( + center=(-length / 2, 0, center_sim[2]), + size=size_port, + direction="+", + mode_spec=mode_spec, + mode_index=0, + source_time=gaussian, + ) + sim = td.Simulation( + center=center_sim, + size=size_sim, + grid_spec=td.GridSpec.uniform(dl=0.1 * mm), + structures=structures, + sources=[mode_src], + monitors=[mode_monitor], + run_time=run_time, + boundary_spec=boundary_spec, + plot_length_units="mm", + symmetry=(0, 0, 0), + ) + return sim + + +def make_minimal_mode_plane_analyzer(**kwargs): + """Helper to create a ModePlaneAnalyzer with minimal simulation data for testing.""" + from tidy3d.components.microwave.path_integrals.mode_plane_analyzer import ( + ModePlaneAnalyzer, + ) + + # Create a minimal simulation for testing + minimal_sim = td.Simulation( + size=(10, 10, 10), + grid_spec=td.GridSpec.uniform(dl=1.0), + run_time=1e-12, + ) + + # Default kwargs + defaults = { + "size": (0, 2, 2), + "center": (0, 0, 0), + "field_data_colocated": False, + "structures": minimal_sim.structures, + "grid": minimal_sim.grid, + "symmetry": minimal_sim.symmetry, + "sim_box": minimal_sim.bounding_box, + } + defaults.update(kwargs) + + return ModePlaneAnalyzer(**defaults) + + +def make_coupled_microstrip_sim(gap=1 * mm, width=3 * mm, height=1 * mm): + """Create simulation with two coupled microstrip lines.""" + freq_start = 1e9 + freq_stop = 10e9 + freq0 = (freq_start + freq_stop) / 2 + fwidth = freq_stop - freq_start + freqs = np.arange(freq_start, freq_stop, 1e9) + run_time = 60 / fwidth + length = 40 * mm + sim_width = 50 * mm + + pec = td.PEC + epsr = 4.4 + diel = td.Medium(permittivity=epsr) + metal_thickness = 0.2 * mm + + # Substrate + substrate = td.Structure( + geometry=td.Box( + center=[0, 0, 0], + size=[td.inf, td.inf, 2 * height], + ), + medium=diel, + name="substrate", + ) + + # Two signal lines + signal_1 = td.Box( + center=[0, -gap / 2 - width / 2, height + metal_thickness / 2], + size=[td.inf, width, metal_thickness], + ) + signal_2 = td.Box( + center=[0, gap / 2 + width / 2, height + metal_thickness / 2], + size=[td.inf, width, metal_thickness], + ) + + # Ground plane (not needed for the tests, but keeps simulation consistent) + ground = td.Box( + center=[0, 0, -metal_thickness / 2], + size=[td.inf, td.inf, metal_thickness], + ) + + structures = [ + substrate, + td.Structure(geometry=signal_1, medium=pec, name="signal_1"), + td.Structure(geometry=signal_2, medium=pec, name="signal_2"), + td.Structure(geometry=ground, medium=pec, name="ground"), + ] + + size_sim = [length + 2 * width, sim_width, 20 * mm + height + metal_thickness] + center_sim = [0, 0, size_sim[2] / 2] + size_port = [0, sim_width, size_sim[2]] + center_port = [0, 0, center_sim[2]] + + impedance_specs = (td.AutoImpedanceSpec(),) * 3 + mode_spec = td.MicrowaveModeSpec( + num_modes=3, + target_neff=1.8, + impedance_specs=impedance_specs, + ) + + mode_monitor = td.MicrowaveModeMonitor( + center=center_port, size=size_port, freqs=freqs, name="mode", colocate=False + ) + + gaussian = td.GaussianPulse(freq0=freq0, fwidth=fwidth) + mode_src = td.ModeSource( + center=(-length / 2, 0, center_sim[2]), + size=size_port, + direction="+", + mode_spec=mode_spec, + mode_index=0, + source_time=gaussian, + ) + + boundary_spec = td.BoundarySpec( + x=td.Boundary(plus=td.PML(), minus=td.PML()), + y=td.Boundary(plus=td.PML(), minus=td.PML()), + z=td.Boundary(plus=td.PML(), minus=td.PECBoundary()), + ) + + return td.Simulation( + center=center_sim, + size=size_sim, + grid_spec=td.GridSpec.uniform(dl=0.1 * mm), + structures=structures, + sources=[mode_src], + monitors=[mode_monitor], + run_time=run_time, + boundary_spec=boundary_spec, + ) + + +def make_stripline_mode_solver( + width=1.0 * mm, + height=0.5 * mm, + metal_thickness=0.1 * mm, + dl=0.05 * mm, + freqs=None, +): + """Create configured stripline mode solver for testing. + + Parameters + ---------- + width : float + Width of the stripline signal conductor. + height : float + Height from signal conductor to ground plane. + metal_thickness : float + Thickness of metal conductors. + dl : float + Grid resolution. + freqs : list of float, optional + Frequency points for mode solver. Defaults to [1e9, 5e9, 10e9]. + + Returns + ------- + tuple[ModeSolver, Simulation] + Configured mode solver and stripline simulation. + """ + from tidy3d.components.mode.mode_solver import ModeSolver + + if freqs is None: + freqs = [1e9, 5e9, 10e9] + + stripline_sim = make_mw_sim( + transmission_line_type="stripline", + width=width, + height=height, + metal_thickness=metal_thickness, + ) + stripline_sim = stripline_sim.updated_copy(grid_spec=td.GridSpec.uniform(dl=dl)) + + plane = td.Box(center=(0, 0, 0), size=(0, 10 * width, 2 * height + metal_thickness)) + impedance_specs = td.AutoImpedanceSpec() + mode_spec = td.MicrowaveModeSpec( + num_modes=3, + target_neff=2.2, + impedance_specs=impedance_specs, + ) + + mms = ModeSolver( + simulation=stripline_sim, + plane=plane, + mode_spec=mode_spec, + colocate=False, + freqs=freqs, + ) + + return mms, stripline_sim diff --git a/tests/test_components/test_simulation.py b/tests/test_components/test_simulation.py index 76f0295192..4f792d6fa3 100644 --- a/tests/test_components/test_simulation.py +++ b/tests/test_components/test_simulation.py @@ -3788,7 +3788,7 @@ def test_structures_per_medium(monkeypatch): def test_validate_microwave_mode_spec(): - """Test that auto generation ande user supplied path specs are correctly validated.""" + """Test that auto generated and user supplied path specs are correctly validated.""" freq0 = 10e9 mm = 1e3 run_time_spec = td.RunTimeSpec(quality_factor=3.0) @@ -3839,7 +3839,7 @@ def test_validate_microwave_mode_spec(): # check that validation error is caught with pytest.raises(SetupError): - sim._validate_microwave_mode_specs() + sim._validate_microwave_mode_plane_analysis() # Custom current spec is too large for mode plane custom_spec = td.CustomImpedanceSpec( diff --git a/tidy3d/__init__.py b/tidy3d/__init__.py index 48adeaf149..7cb748c219 100644 --- a/tidy3d/__init__.py +++ b/tidy3d/__init__.py @@ -24,9 +24,7 @@ MicrowaveModeSolverData, ) from tidy3d.components.microwave.impedance_calculator import ImpedanceCalculator -from tidy3d.components.microwave.mode_spec import ( - MicrowaveModeSpec, -) +from tidy3d.components.microwave.mode_spec import MicrowaveModeSpec, TerminalSpec from tidy3d.components.microwave.monitor import ( MicrowaveModeMonitor, MicrowaveModeSolverMonitor, @@ -799,6 +797,7 @@ def set_logging_level(level: str) -> None: "TemperatureBC", "TemperatureData", "TemperatureMonitor", + "TerminalSpec", "TetrahedralGridDataset", "Transformed", "TriangleMesh", diff --git a/tidy3d/components/microwave/data/monitor_data.py b/tidy3d/components/microwave/data/monitor_data.py index 64051ca5fb..10271177d2 100644 --- a/tidy3d/components/microwave/data/monitor_data.py +++ b/tidy3d/components/microwave/data/monitor_data.py @@ -272,6 +272,32 @@ class MicrowaveModeData(ModeData, MicrowaveBaseModel): "been used to set up the monitor or mode solver.", ) + def _is_transmission_line_mode(self, mode_index: int) -> bool: + """Check if a mode qualifies as a quasi-TEM transmission line mode. + + Parameters + ---------- + mode_index : int + Index of the mode to check. + + Returns + ------- + bool + True if mode is quasi-TEM, False otherwise. + """ + TE_fraction = self.wg_TE_fraction.isel(mode_index=mode_index) + TM_fraction = self.wg_TM_fraction.isel(mode_index=mode_index) + # Evaluate QTEM condition at lowest frequency where TEM behavior is most critical + idx_min = TE_fraction["f"].argmin().item() + TE_fraction_low_f = TE_fraction.isel(f=idx_min) + TM_fraction_low_f = TM_fraction.isel(f=idx_min) + if ( + TE_fraction_low_f >= self.monitor.mode_spec.quasi_tem_threshold + and TM_fraction_low_f >= self.monitor.mode_spec.quasi_tem_threshold + ): + return True + return False + @property def modes_info(self) -> xr.Dataset: """Dataset collecting various properties of the stored modes.""" diff --git a/tidy3d/components/microwave/mode_spec.py b/tidy3d/components/microwave/mode_spec.py index 2f95e406de..e2121dbb7f 100644 --- a/tidy3d/components/microwave/mode_spec.py +++ b/tidy3d/components/microwave/mode_spec.py @@ -6,6 +6,7 @@ import numpy as np import pydantic.v1 as pd +from shapely import Polygon from tidy3d.components.base import cached_property from tidy3d.components.geometry.base import Box @@ -16,10 +17,107 @@ ImpedanceSpecType, ) from tidy3d.components.mode_spec import AbstractModeSpec -from tidy3d.components.types import annotate_type +from tidy3d.components.types import Coordinate2D, annotate_type +from tidy3d.components.types.base import ArrayFloat2D from tidy3d.constants import fp_eps from tidy3d.exceptions import SetupError +# Threshold for determining whether a mode is QuasiTEM +DEFAULT_QUASI_TEM_THRESHOLD = 0.97 + +ConductorIdentifierType = Union[Coordinate2D, str, ArrayFloat2D] + + +class TerminalSpec(MicrowaveBaseModel): + """Specifies the desired voltage pattern across conductors for a transmission line mode. + + Identifies which conductors should be at positive versus negative voltage for mode selection + and ordering in coupled transmission line systems. Conductors can be identified using point + coordinates, structure names, or geometric regions for maximum flexibility. + + Notes + ----- + For most use cases, identifying conductors using a single (x, y) coordinate point or a + structure name string is recommended. If desired, users may specify geometric regions using + arrays of vertices, which are then converted to points, lines, or polygons to identify + conductors via intersections. + + Example + ------- + >>> # Recommended: Identify conductors using point coordinates + >>> # Differential mode: conductor 1 positive, conductor 2 negative + >>> diff_mode = TerminalSpec( + ... plus_terminals=((1.0, 0.5),), # Point inside positive conductor + ... minus_terminals=((-1.0, 0.5),) # Point inside negative conductor + ... ) + >>> + >>> # Common mode: both conductors positive relative to ground + >>> common_mode = TerminalSpec( + ... plus_terminals=((1.0, 0.5), (-1.0, 0.5)), # Both conductors positive + ... minus_terminals=() # Ground plane is reference + ... ) + >>> + >>> # Alternative: Identify conductors by structure name + >>> named_mode = TerminalSpec( + ... plus_terminals=("trace1",), + ... minus_terminals=("trace2",) + ... ) + >>> + >>> # Advanced: Identify conductor using polygon region + >>> import numpy as np + >>> polygon_mode = TerminalSpec( + ... plus_terminals=(np.array([[0, 0], [1, 0], [1, 1], [0, 1]]),), # Square region + ... minus_terminals=() + ... ) + """ + + plus_terminals: tuple[ConductorIdentifierType, ...] = pd.Field( + ..., + title="Positive Terminals", + description="Identifies conductors that should be at positive voltage for this mode. " + "Each conductor can be specified as: (1) a (u, v) coordinate tuple locating a point inside " + "the conductor (recommended), (2) a string matching a structure name, or (3) an Nx2 array " + "of vertices defining a geometric region (point for N=1, line for N=2, polygon for N>2). " + "The coordinate or region should lie within the desired conductor in the mode plane.", + ) + minus_terminals: tuple[ConductorIdentifierType, ...] = pd.Field( + ..., + title="Negative Terminals", + description="Identifies conductors that should be at negative voltage for this mode. " + "Each conductor can be specified as: (1) a (u, v) coordinate tuple locating a point inside " + "the conductor (recommended), (2) a string matching a structure name, or (3) an Nx2 array " + "of vertices defining a geometric region (point for N=1, line for N=2, polygon for N>2). " + "The coordinate or region should lie within the desired conductor in the mode plane.", + ) + + @pd.validator("plus_terminals", "minus_terminals", each_item=True) + def _validate_conductor_identifiers(cls, val): + """Validate conductor identification inputs.""" + + # If it's a string or tuple, pass through + if isinstance(val, (str, tuple)): + return val + + # If it's a numpy array, validate shape and geometry + if isinstance(val, np.ndarray): + # Check that 2D arrays have exactly 2 columns (u, v coordinates) + if val.shape[1] != 2: + raise ValueError( + f"Arrays must have exactly 2 columns for (u, v) coordinates, got shape {val.shape}" + ) + # For 2D arrays, check number of points and polygon validity + num_points = val.shape[0] + if num_points <= 2: + return val + elif num_points > 2: + polygon = Polygon(val) + if not polygon.is_valid: + raise ValueError( + f"A supplied set of vertices {val} did not result in a valid " + "polygon, make sure there are no self-intersections." + ) + return val + class MicrowaveModeSpec(AbstractModeSpec, MicrowaveBaseModel): """ @@ -64,6 +162,26 @@ class MicrowaveModeSpec(AbstractModeSpec, MicrowaveBaseModel): "ignored for the associated mode.", ) + terminal_specs: Optional[tuple[TerminalSpec, ...]] = pd.Field( + None, + title="Terminal Specifications", + description="Optional tuple of terminal specifications for mode selection and ordering in " + "transmission line systems. Each 'TerminalSpec' defines the desired voltage pattern (which conductors " + "should be positive vs. negative) for a mode. When provided, the mode solver automatically reorders " + "computed modes to match the terminal specification order and applies phase corrections to ensure " + "correct voltage polarity.", + ) + + quasi_tem_threshold: float = pd.Field( + DEFAULT_QUASI_TEM_THRESHOLD, + ge=0.0, + le=1.0, + title="Quasi-TEM Mode Threshold", + description="Threshold used to determine whether a mode is a Quasi-TEM mode. " + "If both the TE and TM waveguide polarization fractions are less than this threshold, " + "the mode is considered as a Quasi-TEM mode.", + ) + @cached_property def _impedance_specs_as_tuple(self) -> tuple[Optional[ImpedanceSpecType]]: """Gets the impedance_specs field converted to a tuple.""" diff --git a/tidy3d/components/microwave/path_integrals/mode_plane_analyzer.py b/tidy3d/components/microwave/path_integrals/mode_plane_analyzer.py index 292ec9084c..1ab06b01e9 100644 --- a/tidy3d/components/microwave/path_integrals/mode_plane_analyzer.py +++ b/tidy3d/components/microwave/path_integrals/mode_plane_analyzer.py @@ -7,7 +7,7 @@ import pydantic.v1 as pd import shapely -from shapely.geometry import LineString, Polygon +from shapely.geometry import LineString, Point, Polygon from tidy3d.components.base import cached_property from tidy3d.components.geometry.base import Box, Geometry @@ -21,11 +21,18 @@ ) from tidy3d.components.grid.grid import Grid from tidy3d.components.medium import LossyMetalMedium, Medium +from tidy3d.components.microwave.mode_spec import TerminalSpec from tidy3d.components.structure import Structure from tidy3d.components.types import Axis, Bound, Coordinate, Shapely, Symmetry from tidy3d.components.validators import assert_plane from tidy3d.exceptions import SetupError +# Type for holding sets of indices associated with conductors, +# where the first set contains positive terminals and the second set contains negative terminals. +VoltageSets = tuple[set[int], set[int]] +# Conversion of user-supplied TerminalSpec into shapely geometries which are used to find intersecting conductors +TerminalSpecShapes = tuple[list[Shapely], list[Shapely]] + class ModePlaneAnalyzer(Box): """Analyzes conductor geometry intersecting a mode plane. @@ -42,6 +49,30 @@ class ModePlaneAnalyzer(Box): "are placed with additional margin to avoid interpolated field values near conductor surfaces.", ) + structures: tuple[Structure, ...] = pd.Field( + ..., + title="Structures", + description="Tuple of structures in the simulation to analyze for conductors.", + ) + + grid: Grid = pd.Field( + ..., + title="Grid", + description="Simulation grid for snapping paths to field data positions.", + ) + + symmetry: tuple[Symmetry, Symmetry, Symmetry] = pd.Field( + (0, 0, 0), + title="Symmetry", + description="Symmetry conditions for the simulation in (x, y, z) directions.", + ) + + sim_box: Box = pd.Field( + ..., + title="Simulation Box", + description="Simulation domain box used for boundary condition analysis.", + ) + @cached_property def _snap_spec(self) -> SnappingSpec: """Creates snapping specification for bounding boxes.""" @@ -52,6 +83,111 @@ def _snap_spec(self) -> SnappingSpec: margin = (2, 2, 2) if self.field_data_colocated else (0, 0, 0) return SnappingSpec(location=location, behavior=behavior, margin=margin) + @cached_property + def mode_symmetry(self) -> tuple[Symmetry, Symmetry, Symmetry]: + """Mode symmetry considering simulation box and simulation symmetry.""" + return self._get_mode_symmetry(self.sim_box, self.symmetry) + + @cached_property + def mode_limits(self) -> Bound: + """Mode plane bounds restricted to final grid positions. + + Mode profiles are calculated on a grid which is expanded from the monitor size + to the closest grid boundaries, taking into account symmetry conditions. + """ + return self._get_mode_limits(self.grid, self.mode_symmetry) + + @cached_property + def conductor_shapes(self) -> list[Shapely]: + """Isolated conductor geometries in the mode plane. + + Finds all PEC/metal structures, merges touching conductors, and filters out + grounded conductors (those touching PEC boundaries). + + Returns + ------- + list[Shapely] + List of conductor geometries after merging and filtering. + + Raises + ------ + SetupError + If no valid isolated conductors are found in the mode plane. + """ + min_b_3d, max_b_3d = self.mode_limits + + intersection_plane = Box.from_bounds(min_b_3d, max_b_3d) + conductor_shapely = self._get_isolated_conductors_as_shapely( + intersection_plane, self.structures + ) + + conductor_shapely = self._filter_conductors_touching_sim_bounds( + (min_b_3d, max_b_3d), self.mode_symmetry, conductor_shapely + ) + + if len(conductor_shapely) < 1: + raise SetupError( + "No valid isolated conductors were found in the mode plane. Please ensure that a 'Structure' " + "with a medium of type 'PEC' or 'LossyMetalMedium' intersects the mode plane and is not touching " + "the boundaries of the mode plane." + ) + + return conductor_shapely + + @cached_property + def conductor_bounding_boxes(self) -> list[Box]: + """Bounding boxes encompassing each isolated conductor. + + Each box is snapped to the grid and includes all symmetry-reflected regions. + + Returns + ------- + list[Box] + List of bounding boxes, one per isolated conductor. + + Raises + ------ + SetupError + If a generated bounding box intersects with a conductor. + """ + + def bounding_box_from_shapely(geom: Shapely) -> Box: + """Helper to convert the shapely geometry bounds to a Box.""" + bounds = geom.bounds + normal_center = self.center[self._normal_axis] + rmin = Geometry.unpop_axis(normal_center, (bounds[0], bounds[1]), self._normal_axis) + rmax = Geometry.unpop_axis(normal_center, (bounds[2], bounds[3]), self._normal_axis) + return Box.from_bounds(rmin, rmax) + + # Get desired snapping behavior of box enclosed conductors. + # Ideally, just large enough to coincide with the H field positions outside of the conductor. + # So a half grid cell, when the metal boundary is coincident with grid boundaries. + snap_spec = self._snap_spec + + bounding_boxes = [] + for shape in self.conductor_shapes: + box = bounding_box_from_shapely(shape) + boxes = self._apply_symmetries(self.symmetry, self.sim_box.center, box) + for box in boxes: + box_snapped = snap_box_to_grid(self.grid, box, snap_spec) + bounding_boxes.append(box_snapped) + + for bounding_box in bounding_boxes: + if self._check_box_intersects_with_conductors(self.conductor_shapes, bounding_box): + raise SetupError( + "Failed to automatically generate path specification because a generated path " + "specification was found to intersect with a conductor. There is currently limited " + "support for complex conductor geometries, so please provide an explicit current " + "path specification through a 'CustomImpedanceSpec'. Alternatively, enforce a " + "smaller grid around the conductors in the mode plane, which may resolve the issue." + ) + return bounding_boxes + + @cached_property + def num_conductors(self) -> int: + """Number of isolated conductors in the mode plane.""" + return len(self.conductor_shapes) + def _get_mode_symmetry( self, sim_box: Box, sym_symmetry: tuple[Symmetry, Symmetry, Symmetry] ) -> tuple[Symmetry, Symmetry, Symmetry]: @@ -166,85 +302,178 @@ def _filter_conductors_touching_sim_bounds( ml_pec_bounds = shapely.MultiLineString(shapely_pec_bounds) return [shape for shape in conductor_polygons if not ml_pec_bounds.intersects(shape)] - def get_conductor_bounding_boxes( + def _convert_terminal_specifications_to_candidate_geometry( + self, structures: list[Structure], terminal_specs: list[TerminalSpec] + ) -> list[TerminalSpecShapes]: + """Converts the different methods for specifying terminals in `TerminalSpec` into + Shapely geometries. The intersection of these geometries with conductor polygons identifies + the terminals in the mode plane. + """ + + def find_structure_by_name(target_name): + """Find first element where name matches target_name.""" + return next( + (item for item in structures if item.name is not None and item.name == target_name), + None, + ) + + def convert_terminal(terminal_specifier) -> Shapely: + if isinstance(terminal_specifier, tuple): + return Point(*terminal_specifier) + elif isinstance(terminal_specifier, str): + structure = find_structure_by_name(terminal_specifier) + if structure is None: + raise SetupError( + f"No structure found with name '{terminal_specifier}'. " + "Please ensure that a `Structure` with the same name has been added to the simulation." + ) + shapes_plane = self.intersections_with(structure.geometry) + return shapes_plane + elif terminal_specifier.shape[0] == 1: + return Point(*terminal_specifier) + elif terminal_specifier.shape[0] == 2: + return LineString(terminal_specifier) + else: + return Polygon(terminal_specifier) + + terminal_spec_shapes = [] + for terminal_spec in terminal_specs: + plus_spec_shapes = [ + convert_terminal(plus_terminal) for plus_terminal in terminal_spec.plus_terminals + ] + minus_spec_shapes = [ + convert_terminal(minus_terminal) for minus_terminal in terminal_spec.minus_terminals + ] + terminal_spec_shapes.append((plus_spec_shapes, minus_spec_shapes)) + return terminal_spec_shapes + + def _find_conductor_terminals( self, - structures: list[Structure], - grid: Grid, - symmetry: tuple[Symmetry, Symmetry, Symmetry], - sim_box: Box, - ) -> tuple[list[Box], list[Shapely]]: - """Returns bounding boxes that encompass each isolated conductor - in the mode plane. + conductor_shapely: list[Shapely], + terminal_specs: list[tuple[list[Shapely], list[Shapely]]], + ) -> list[VoltageSets]: + """Converts terminal specifications given as shapely geometry to conductor indices. - This method identifies isolated conductor geometries in the given plane. - The paths are snapped to the simulation grid - to ensure alignment with field data. + For each terminal spec, identifies which conductors contain the specified terminal + coordinates and returns sets of positive and negative conductor indices. Parameters ---------- - structures : list - List of structures in the simulation. - grid : Grid - Simulation grid for snapping paths. - symmetry : tuple[Symmetry, Symmetry, Symmetry] - Symmetry conditions for the simulation in (x, y, z) directions. - sim_box : Box - Simulation domain box used for boundary conditions. + conductor_shapely : list[Shapely] + List of conductor geometries in the mode plane. + terminal_specs : list[Shapely] + Terminal specifications with (x, y) coordinates for positive and negative terminals. Returns ------- - tuple[list[Box], list[Shapely]] - Bounding boxes and list of merged conductor geometries. + list[VoltageSets] + List of (positive_conductor_indices, negative_conductor_indices) for each terminal spec. """ - def bounding_box_from_shapely(geom: Shapely) -> Box: - """Helper to convert the shapely geometry bounds to a Box.""" - bounds = geom.bounds - normal_center = self.center[self._normal_axis] - rmin = Geometry.unpop_axis(normal_center, (bounds[0], bounds[1]), self._normal_axis) - rmax = Geometry.unpop_axis(normal_center, (bounds[2], bounds[3]), self._normal_axis) - return Box.from_bounds(rmin, rmax) + def validate_conductor_intersection(indices: list[int], terminal_type: str) -> None: + """Validate that exactly one conductor intersects with the terminal.""" + if len(indices) == 0: + raise SetupError( + f"No conductor found intersecting with the {terminal_type}_terminal. " + "Please ensure that your terminal specification (coordinate, line, or polygon) " + "intersects with at least one conductive structure in the mode plane. " + "Check that the terminal coordinates are within the bounds of a conductor." + ) + elif len(indices) > 1: + raise SetupError( + f"Multiple conductors ({len(indices)}) found intersecting with the {terminal_type}_terminal. " + "Please ensure that your terminal specification intersects with exactly one conductor. " + "Consider making your terminal specification more precise (e.g., using a smaller region or point) " + "to uniquely identify a single conductor." + ) - mode_symmetry_3d = self._get_mode_symmetry(sim_box, symmetry) - min_b_3d, max_b_3d = self._get_mode_limits(grid, mode_symmetry_3d) + terminals = [] + for term_spec in terminal_specs: + all_plus_indices = set() + all_minus_indices = set() + for plus_terminal in term_spec[0]: + plus_indices = [ + i for i, geom in enumerate(conductor_shapely) if geom.intersects(plus_terminal) + ] + validate_conductor_intersection(plus_indices, "plus") + all_plus_indices.update(plus_indices) + for minus_terminal in term_spec[1]: + minus_indices = [ + i for i, geom in enumerate(conductor_shapely) if geom.intersects(minus_terminal) + ] + validate_conductor_intersection(minus_indices, "minus") + all_minus_indices.update(minus_indices) + terminals.append((all_plus_indices, all_minus_indices)) + return terminals - intersection_plane = Box.from_bounds(min_b_3d, max_b_3d) - conductor_shapely = self._get_isolated_conductors_as_shapely(intersection_plane, structures) + def _identify_conductor_voltage_sets( + self, terminal_specs: tuple[TerminalSpec, ...] + ) -> list[VoltageSets]: + """Identifies the conductor polygons associated with the supplied `TerminalSpec`. + The conductor polygons are identified through their index into `self.conductor_shapes`. - conductor_shapely = self._filter_conductors_touching_sim_bounds( - (min_b_3d, max_b_3d), mode_symmetry_3d, conductor_shapely + Parameters + ---------- + terminal_specs : tuple[TerminalSpec, ...] + Terminal specifications with (x, y) coordinates for positive and negative terminals. + + Returns + ------- + list[VoltageSets] + List of (positive_conductor_indices, negative_conductor_indices) for each terminal spec.""" + + conductor_shapes = self.conductor_shapes + + terminal_spec_shapes = self._convert_terminal_specifications_to_candidate_geometry( + self.structures, terminal_specs ) + terminals = self._find_conductor_terminals(conductor_shapes, terminal_spec_shapes) + return terminals - if len(conductor_shapely) < 1: - raise SetupError( - "No valid isolated conductors were found in the mode plane. Please ensure that a 'Structure' " - "with a medium of type 'PEC' or 'LossyMetalMedium' intersects the mode plane and is not touching " - "the boundaries of the mode plane." - ) + def _validate_conductor_voltage_configurations( + self, conductor_voltage_sets: list[VoltageSets] + ) -> None: + """Validates terminal specifications for conflicts and duplicates. - # Get desired snapping behavior of box enclosed conductors. - # Ideally, just large enough to coincide with the H field positions outside of the conductor. - # So a half grid cell, when the metal boundary is coincident with grid boundaries. - snap_spec = self._snap_spec + Checks that no conductor appears in both positive and negative sets, and that + no duplicate configurations exist (including polarity-reversed duplicates). - bounding_boxes = [] - for shape in conductor_shapely: - box = bounding_box_from_shapely(shape) - boxes = self._apply_symmetries(symmetry, sim_box.center, box) - for box in boxes: - box_snapped = snap_box_to_grid(grid, box, snap_spec) - bounding_boxes.append(box_snapped) + Parameters + ---------- + conductor_voltage_sets : list[VoltageSets] + List of (positive_conductor_indices, negative_conductor_indices) to validate. + + Raises + ------ + SetupError + If a conductor appears in both positive and negative sets, or if duplicate + configurations are detected. + """ + # Check that a conductor index only belongs in either plus or minus sets + for voltage_set in conductor_voltage_sets: + if not voltage_set[0].isdisjoint(voltage_set[1]): + raise SetupError( + "A conductor cannot be assigned to both a positive and negative voltage." + ) - for bounding_box in bounding_boxes: - if self._check_box_intersects_with_conductors(conductor_shapely, bounding_box): + # Check that only unique polarity configurations exist + # Two configurations are considered the same if one is the polarity-reversed version of the other + unique_terminal_configuration = set() + + for voltage_set in conductor_voltage_sets: + pos, neg = voltage_set + + # Create a normalized representation that treats (pos, neg) and (neg, pos) as equivalent + # Using frozenset of frozensets ensures order-independence + terminal_configuration = frozenset([frozenset(pos), frozenset(neg)]) + + if terminal_configuration in unique_terminal_configuration: raise SetupError( - "Failed to automatically generate path specification because a generated path " - "specification was found to intersect with a conductor. There is currently limited " - "support for complex conductor geometries, so please provide an explicit current " - "path specification through a 'CustomImpedanceSpec'. Alternatively, enforce a " - "smaller grid around the conductors in the mode plane, which may resolve the issue." + "Duplicate voltage configuration detected. " + "Each unique pair of conductor sets (including polarity-reversed pairs) can only appear once." ) - return bounding_boxes, conductor_shapely + + unique_terminal_configuration.add(terminal_configuration) def _check_box_intersects_with_conductors( self, shapely_list: list[Shapely], bounding_box: Box diff --git a/tidy3d/components/mode/mode_solver.py b/tidy3d/components/mode/mode_solver.py index 17a861dcbb..8254a8992e 100644 --- a/tidy3d/components/mode/mode_solver.py +++ b/tidy3d/components/mode/mode_solver.py @@ -44,6 +44,7 @@ from tidy3d.components.microwave.mode_spec import MicrowaveModeSpec from tidy3d.components.microwave.monitor import MicrowaveModeMonitor, MicrowaveModeSolverMonitor from tidy3d.components.microwave.path_integrals.factory import make_path_integrals +from tidy3d.components.microwave.path_integrals.mode_plane_analyzer import ModePlaneAnalyzer from tidy3d.components.mode_spec import ModeSpec from tidy3d.components.monitor import ModeMonitor, ModeSolverMonitor from tidy3d.components.scene import Scene @@ -268,6 +269,12 @@ def _post_init_validators(self) -> None: self._validate_num_grid_points() if self._has_microwave_mode_spec: self._validate_microwave_mode_spec(mode_spec=self.mode_spec, plane=self.plane) + self._validate_mode_plane_analysis( + sim=self.simulation, + mode_spec=self.mode_spec, + plane=self.plane, + colocate=self.colocate, + ) @classmethod def _warn_thick_pml( @@ -345,6 +352,42 @@ def _validate_microwave_mode_spec(cls, mode_spec: MicrowaveModeSpec, plane: Box) """Validate that the microwave mode spec is correctly setup.""" mode_spec._check_path_integrals_within_box(plane) + @classmethod + def _validate_mode_plane_analysis( + cls, + sim: Simulation, + mode_spec: MicrowaveModeSpec, + plane: Box, + colocate: bool, + ) -> None: + """Check that the mode plane analysis works without issue.""" + if mode_spec._using_auto_current_spec or mode_spec.terminal_specs is not None: + try: + mode_plane_analyzer = ModePlaneAnalyzer( + center=plane.center, + size=plane.size, + field_data_colocated=colocate, + structures=sim.volumetric_structures, + grid=sim.grid, + symmetry=sim.symmetry, + sim_box=sim.simulation_geometry, + ) + _ = mode_plane_analyzer.conductor_bounding_boxes + + except SetupError as e: + raise SetupError( + f"Failed to automatically place paths around conductors in the mode plane. {e!s}" + ) from e + + if mode_spec.terminal_specs is not None: + try: + voltage_sets = mode_plane_analyzer._identify_conductor_voltage_sets( + mode_spec.terminal_specs + ) + mode_plane_analyzer._validate_conductor_voltage_configurations(voltage_sets) + except SetupError as e: + raise SetupError(f"'TerminalSpec' was not setup correctly. {e!s}") from e + @cached_property def normal_axis(self) -> Axis: """Axis normal to the mode plane.""" @@ -1407,10 +1450,22 @@ def _make_path_integrals( ) return make_path_integrals(self.mode_spec) + def _post_process_modes_with_terminal_specs( + self, + mode_solver_data: MicrowaveModeSolverData, + ) -> MicrowaveModeSolverData: + """Select, sort and post process modes to match terminal specifications.""" + raise SetupError("Terminal-based mode setup is not available for the local mode solver.") + def _add_microwave_data( self, mode_solver_data: MicrowaveModeSolverData ) -> MicrowaveModeSolverData: """Calculate and add microwave data to ``mode_solver_data`` which uses the path specifications.""" + + # Check if terminal specifications are present and will be used to drive the mode ordering and selection + if self.mode_spec.terminal_specs is not None: + mode_solver_data = self._post_process_modes_with_terminal_specs(mode_solver_data) + voltage_integrals, current_integrals = self._make_path_integrals() # Need to operate on the full symmetry expanded fields mode_solver_data_expanded = mode_solver_data.symmetry_expanded_copy @@ -1848,7 +1903,7 @@ def _grid_correction( mode_spec: ModeSpec, n_complex: ModeIndexDataArray, direction: Direction, - ) -> [FreqModeDataArray, FreqModeDataArray]: + ) -> tuple[FreqModeDataArray, FreqModeDataArray]: """Correct the fields due to propagation on the grid. Return a copy of the :class:`.ModeSolverData` with the fields renormalized to account @@ -1864,7 +1919,7 @@ def _grid_correction( Returns ------- - :class:`.ModeSolverData` + tuple[:class:`.FreqModeDataArray`, :class:`.FreqModeDataArray`] Copy of the data with renormalized fields. """ normal_axis = plane.size.index(0.0) diff --git a/tidy3d/components/simulation.py b/tidy3d/components/simulation.py index 1074899775..d562c37caf 100644 --- a/tidy3d/components/simulation.py +++ b/tidy3d/components/simulation.py @@ -77,7 +77,6 @@ PECMedium, ) from .microwave.monitor import MicrowaveModeMonitor, MicrowaveModeSolverMonitor -from .microwave.path_integrals.mode_plane_analyzer import ModePlaneAnalyzer from .monitor import ( AbstractFieldProjectionMonitor, AbstractModeMonitor, @@ -4478,7 +4477,7 @@ def validate_pre_upload(self, source_required: bool = True) -> None: self._warn_time_monitors_outside_run_time() self._validate_time_monitors_num_steps() self._validate_freq_monitors_freq_range() - self._validate_microwave_mode_specs() + self._validate_microwave_mode_plane_analysis() log.end_capture(self) if source_required and len(self.sources) == 0: raise SetupError("No sources in simulation.") @@ -4667,29 +4666,24 @@ def _validate_freq_monitors_freq_range(self) -> None: "(Hz) as defined by the sources." ) - def _validate_microwave_mode_specs(self) -> None: - """Raise error if any microwave mode specifications with ``AutoImpedanceSpec`` will - fail to instantiate. - """ + def _validate_microwave_mode_plane_analysis(self) -> None: + """Raise error if mode plane analysis fails for a `MicrowaveModeMonitor` or `MicrowaveModeSolverMonitor`. + Requires the grid and volumetric_structures information, so perform at pre upload stage.""" + from tidy3d.components.mode.mode_solver import ModeSolver + for monitor in self.monitors: if not isinstance(monitor, (MicrowaveModeMonitor, MicrowaveModeSolverMonitor)): continue - - if monitor.mode_spec._using_auto_current_spec: - mode_plane_analyzer = ModePlaneAnalyzer( - center=monitor.center, size=monitor.size, field_data_colocated=monitor.colocate + mw_mode_spec = monitor.mode_spec + plane = monitor.geometry + try: + ModeSolver._validate_mode_plane_analysis( + self, mw_mode_spec, plane, colocate=monitor.colocate ) - try: - _ = mode_plane_analyzer.get_conductor_bounding_boxes( - self.volumetric_structures, - self.grid, - self.symmetry, - self.simulation_geometry, - ) - except SetupError as e: - raise SetupError( - f"Failed to setup auto impedance specification for monitor '{monitor.name}'. {e!s}" - ) from e + except SetupError as e: + raise SetupError( + f"Failed to perform mode plane analysis for monitor '{monitor.name}'. {e!s}" + ) from e @cached_property def monitors_data_size(self) -> dict[str, float]: