Skip to content

Commit ae9c05c

Browse files
committed
feat(rf): Add RF specific mode characteristics to MicrowaveModeData
final
1 parent ac9970b commit ae9c05c

File tree

8 files changed

+298
-6
lines changed

8 files changed

+298
-6
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939
- Added warning if port mesh refinement is incompatible with the `GridSpec` in the `TerminalComponentModeler`.
4040
- Various types, e.g. different `Simulation` or `SimulationData` sub-classes, can be loaded from file directly with `Tidy3dBaseModel.from_file()`.
4141
- Added `interp_spec` in `EMEModeSpec` to enable faster multi-frequency EME simulations. Note that the default is now `ModeInterpSpec.cheb(num_points=3, reduce_data=True)`; previously the computation was repeated at all frequencies.
42+
- Added more RF-specific mode characteristics to `MicrowaveModeData`, including propagation constants (alpha, beta, gamma), phase/group velocities, wave impedance, and automatic mode classification with configurable polarization thresholds in `MicrowaveModeSpec`.
4243

4344
### Breaking Changes
4445
- Edge singularity correction at PEC and lossy metal edges defaults to `True`.

tests/test_components/test_microwave.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,7 @@ def make_mw_sim(
384384
boundary_spec=boundary_spec,
385385
plot_length_units="mm",
386386
symmetry=(0, 0, 0),
387+
subpixel=False,
387388
)
388389
return sim
389390

@@ -1099,16 +1100,15 @@ def test_mode_solver_with_microwave_mode_spec():
10991100
num_modes = 3
11001101
impedance_specs = td.AutoImpedanceSpec()
11011102
mode_spec = td.MicrowaveModeSpec(
1102-
num_modes=num_modes,
1103-
target_neff=2.2,
1104-
impedance_specs=impedance_specs,
1103+
num_modes=num_modes, target_neff=2.2, impedance_specs=impedance_specs
11051104
)
1105+
freqs = (1e9, 5e9, 10e9)
11061106
mms = ModeSolver(
11071107
simulation=stripline_sim,
11081108
plane=plane,
11091109
mode_spec=mode_spec,
11101110
colocate=False,
1111-
freqs=[1e9, 5e9, 10e9],
1111+
freqs=freqs,
11121112
)
11131113

11141114
# _, ax = plt.subplots(1, 1, tight_layout=True, figsize=(15, 15))
@@ -1145,6 +1145,50 @@ def test_mode_solver_with_microwave_mode_spec():
11451145
np.isclose(mms_data.transmission_line_data.Z0.real.sel(mode_index=0), 28.6, rtol=0.2)
11461146
)
11471147

1148+
# Test RF-specific mode characteristics
1149+
e_r = 4.4
1150+
k0 = 1e6 * 2 * np.pi * np.array(freqs) / td.C_0
1151+
n_eff = np.sqrt(e_r)
1152+
# 1. Mode classification (stripline should support TEM mode)
1153+
assert mms_data.mode_classifications[0] == "TEM", (
1154+
f"Expected TEM mode for stripline, got {mms_data.mode_classifications[0]}"
1155+
)
1156+
1157+
assert np.allclose(mms_data.effective_relative_permittivity.sel(mode_index=0).real, e_r)
1158+
assert np.allclose(
1159+
mms_data.effective_relative_permittivity.sel(mode_index=0).imag, 0.0, atol=1e-6
1160+
)
1161+
1162+
# Attenuation constant (nearly zero for lossless line)
1163+
alpha = mms_data.alpha.sel(mode_index=0)
1164+
assert np.allclose(alpha, 0.0, atol=1e-6)
1165+
1166+
# Phase constant (positive, increases with frequency)
1167+
beta = mms_data.beta.sel(mode_index=0)
1168+
assert np.allclose(beta, k0 * n_eff)
1169+
1170+
# Propagation constant (gamma = -alpha + j*beta)
1171+
gamma = mms_data.gamma.sel(mode_index=0)
1172+
assert np.allclose(gamma.real, 0.0, atol=1e-6)
1173+
assert np.allclose(gamma.imag, k0 * n_eff)
1174+
1175+
# Phase velocity (v_p ~ c/n_eff)
1176+
v_p = mms_data.phase_velocity.sel(mode_index=0)
1177+
expected_v_p = td.C_0 * 1e-6 / n_eff
1178+
assert np.allclose(v_p, expected_v_p, rtol=1e-6)
1179+
1180+
# Wave impedance (should be positive and physically reasonable)
1181+
Z_wave = mms_data.wave_impedance.sel(mode_index=0)
1182+
assert np.allclose(Z_wave.real, td.ETA_0 / n_eff, rtol=1e-4)
1183+
assert np.allclose(Z_wave.imag, 0.0, atol=1e-6)
1184+
1185+
# Distance for 40dB (very large for low-loss line)
1186+
d_40dB = mms_data.distance_40dB.sel(mode_index=0)
1187+
assert np.all(d_40dB > 100)
1188+
1189+
with AssertLogLevel("WARNING", contains_str="The 'group_velocity' was not computed."):
1190+
mms_data.group_velocity
1191+
11481192
# Make sure a single spec can be used
11491193
microwave_spec_custom = td.MicrowaveModeSpec(
11501194
num_modes=num_modes, target_neff=2.2, impedance_specs=custom_spec
@@ -1207,6 +1251,7 @@ def test_mode_solver_with_microwave_group_index():
12071251

12081252
# Verify that group index was calculated
12091253
assert mms_data.n_group is not None, "Group index should be calculated"
1254+
assert mms_data.group_velocity is not None, "Group velocity should be calculated"
12101255

12111256
# Verify that transmission line data exists
12121257
assert mms_data.transmission_line_data is not None, "Transmission line data should exist"
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
from __future__ import annotations
2+
3+
from tidy3d.components.data.data_array import FreqModeDataArray
4+
from tidy3d.constants import NEPERPERMETER, PERMETER, RADPERMETER, VELOCITY_SI
5+
6+
7+
class PropagationConstantArray(FreqModeDataArray):
8+
__slots__ = ()
9+
_data_attrs = {"units": PERMETER, "long_name": "propagation constant"}
10+
11+
12+
class PhaseConstantArray(FreqModeDataArray):
13+
__slots__ = ()
14+
_data_attrs = {"units": RADPERMETER, "long_name": "phase constant"}
15+
16+
17+
class AttenuationConstantArray(FreqModeDataArray):
18+
__slots__ = ()
19+
_data_attrs = {"units": NEPERPERMETER, "long_name": "attenuation constant"}
20+
21+
22+
class PhaseVelocityArray(FreqModeDataArray):
23+
__slots__ = ()
24+
_data_attrs = {"units": VELOCITY_SI, "long_name": "phase velocity"}
25+
26+
27+
class GroupVelocityArray(FreqModeDataArray):
28+
__slots__ = ()
29+
_data_attrs = {"units": VELOCITY_SI, "long_name": "group velocity"}

tidy3d/components/microwave/data/monitor_data.py

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,31 @@
66

77
from typing import Literal, Optional
88

9+
import numpy as np
910
import pydantic.v1 as pd
1011
import xarray as xr
1112
from typing_extensions import Self
1213

13-
from tidy3d.components.data.data_array import FieldProjectionAngleDataArray, FreqDataArray
14+
from tidy3d.components.data.data_array import (
15+
FieldProjectionAngleDataArray,
16+
FreqDataArray,
17+
FreqModeDataArray,
18+
ImpedanceFreqModeDataArray,
19+
)
1420
from tidy3d.components.data.monitor_data import DirectivityData, ModeData, ModeSolverData
1521
from tidy3d.components.microwave.base import MicrowaveBaseModel
22+
from tidy3d.components.microwave.data.data_array import (
23+
AttenuationConstantArray,
24+
GroupVelocityArray,
25+
PhaseConstantArray,
26+
PhaseVelocityArray,
27+
PropagationConstantArray,
28+
)
1629
from tidy3d.components.microwave.data.dataset import TransmissionLineDataset
1730
from tidy3d.components.microwave.monitor import MicrowaveModeMonitor, MicrowaveModeSolverMonitor
18-
from tidy3d.components.types import FreqArray, PolarizationBasis
31+
from tidy3d.components.types import FreqArray, ModeClassification, PolarizationBasis
32+
from tidy3d.constants import C_0
33+
from tidy3d.log import log
1934

2035

2136
class AntennaMetricsData(DirectivityData, MicrowaveBaseModel):
@@ -242,6 +257,160 @@ def modes_info(self) -> xr.Dataset:
242257
super_info["Im(Z0)"] = self.transmission_line_data.Z0.imag
243258
return super_info
244259

260+
@property
261+
def mode_classifications(self) -> list[ModeClassification]:
262+
"""List of mode classifications (TEM, quasi-TEM, TE, TM, or Hybrid) for each mode."""
263+
return [self._classify_mode(mode_index) for mode_index in self.n_complex.mode_index]
264+
265+
@property
266+
def free_space_wavenumber(self) -> FreqDataArray:
267+
"""The free space wavenumber (k_0) in rad/m."""
268+
freqs = self.n_complex.f.values
269+
C_0_meters = C_0 * 1e-6
270+
return FreqDataArray(2 * np.pi * freqs / C_0_meters, coords={"f": freqs})
271+
272+
@property
273+
def gamma(self) -> PropagationConstantArray:
274+
r"""The propagation constant with SI units.
275+
276+
In the physics convention, where time-harmonic fields evolve with :math:`e^{-\omega t}`,
277+
a wave propagating in the +z direction varies as:
278+
279+
.. math::
280+
281+
E(z) = E_0 e^{\gamma z} = E_0 e^{-\alpha z} e^{j\beta z}
282+
283+
where :math:`\gamma = -\alpha + j\beta`.
284+
"""
285+
data = 1j * self.n_complex * self.free_space_wavenumber
286+
return PropagationConstantArray(data, coords=self.n_complex.coords)
287+
288+
@property
289+
def alpha(self) -> AttenuationConstantArray:
290+
r"""The attenuation constant (real part of :math:`\gamma`).
291+
292+
Causes exponential decay of the field amplitude:
293+
294+
.. math::
295+
296+
E(z) = E_0 e^{-\alpha z} e^{j\beta z}
297+
298+
Units: Nepers/meter (Np/m).
299+
"""
300+
return -self.gamma.real
301+
302+
@property
303+
def beta(self) -> PhaseConstantArray:
304+
r"""The phase constant (imaginary part of :math:`\gamma`).
305+
306+
Determines the phase variation of the field:
307+
308+
.. math::
309+
310+
E(z) = E_0 e^{-\alpha z} e^{j\beta z}
311+
312+
Units: radians/meter (rad/m).
313+
"""
314+
return self.gamma.imag
315+
316+
@property
317+
def distance_40dB(self) -> FreqModeDataArray:
318+
r"""Distance at which the field amplitude drops by 40 dB.
319+
320+
For a lossy transmission line, this is the distance where the signal
321+
attenuates by 40 dB:
322+
323+
.. math::
324+
325+
d_{40\text{dB}} = \frac{40\,\text{dB}}{20 \log_{10}(e) \cdot \alpha} = \frac{40}{8.686 \cdot \alpha}
326+
327+
where :math:`\alpha` is the attenuation constant in Nepers/meter.
328+
329+
Units: meters.
330+
"""
331+
# Convert attenuation from Nepers/m to dB/m: dB/m = 20*log10(e)*Np/m ≈ 8.686*Np/m
332+
# Then: distance_40dB = 40 dB / (attenuation in dB/m)
333+
attenuation_dB_per_m = 20 * np.log10(np.e) * self.alpha
334+
distance_meters = 40 / attenuation_dB_per_m
335+
return FreqModeDataArray(distance_meters.values, coords=self.alpha.coords)
336+
337+
@property
338+
def effective_relative_permittivity(self) -> FreqModeDataArray:
339+
"""Effective relative permittivity (real part of n_eff²)."""
340+
e_r_complex = self.n_complex * self.n_complex
341+
return FreqModeDataArray(e_r_complex.values, coords=self.n_complex.coords)
342+
343+
@property
344+
def phase_velocity(self) -> PhaseVelocityArray:
345+
"""Phase velocity (v_p = c/n_eff) in m/s."""
346+
C_0_meters = C_0 * 1e-6
347+
v_p = C_0_meters / self.n_eff
348+
return PhaseVelocityArray(v_p.values, coords=self.n_eff.coords)
349+
350+
@property
351+
def group_velocity(self) -> Optional[GroupVelocityArray]:
352+
"""Group velocity (v_g = c/n_group) in m/s."""
353+
if self.n_group_raw is None:
354+
log.warning(
355+
"The 'group_velocity' was not computed. To calculate 'group_velocity' index, pass "
356+
"'group_index_step = True' in the 'MicrowaveModeSpec'.",
357+
log_once=True,
358+
)
359+
return None
360+
C_0_meters = C_0 * 1e-6
361+
v_g = C_0_meters / self.n_group
362+
return GroupVelocityArray(v_g.values, coords=self.n_eff.coords)
363+
364+
@property
365+
def wave_impedance(self) -> ImpedanceFreqModeDataArray:
366+
r"""Compute the wave impedance associated with the waveguide mode.
367+
The wave impedance is defined as:
368+
369+
.. math::
370+
371+
Z_{\rm wave} = \frac{\int |E_t|^2 \, {\rm d}S}{2 P}.
372+
"""
373+
self._check_fields_stored(["Ex", "Ey", "Ez", "Hx", "Hy", "Hz"])
374+
375+
tan_fields = self._colocated_tangential_fields
376+
dim1, dim2 = self._tangential_dims
377+
e1 = tan_fields["E" + dim1]
378+
e2 = tan_fields["E" + dim2]
379+
diff_area = self._diff_area
380+
field_int = [np.abs(e_field) ** 2 for e_field in [e1, e2]]
381+
tangential_intensity = (diff_area * (field_int[0] + field_int[1])).sum(
382+
dim=self._tangential_dims
383+
)
384+
Z_wave = tangential_intensity / self.complex_flux / 2
385+
return ImpedanceFreqModeDataArray(Z_wave.values, coords=self.flux.coords)
386+
387+
def _classify_mode(self, mode_index: int) -> ModeClassification:
388+
"""Classify mode as TEM, quasi-TEM, TE, TM, or Hybrid based on TE/TM fractions."""
389+
# Make quasi-TEM classification choice based on lowest frequency available
390+
min_f_idx = self.wg_TE_fraction.f.argmin()
391+
low_f_TE_frac = self.wg_TE_fraction.sel(mode_index=mode_index).isel(f=min_f_idx).values
392+
low_f_TM_frac = self.wg_TM_fraction.sel(mode_index=mode_index).isel(f=min_f_idx).values
393+
# Otherwise we use the average value of the fraction across frequencies
394+
mean_TE_frac = self.wg_TE_fraction.sel(mode_index=mode_index).mean().values
395+
mean_TM_frac = self.wg_TM_fraction.sel(mode_index=mode_index).mean().values
396+
397+
if (
398+
mean_TE_frac >= self.monitor.mode_spec.tem_polarization_threshold
399+
and mean_TM_frac >= self.monitor.mode_spec.tem_polarization_threshold
400+
):
401+
return "TEM"
402+
elif (
403+
low_f_TE_frac >= self.monitor.mode_spec.qtem_polarization_threshold
404+
and low_f_TM_frac >= self.monitor.mode_spec.qtem_polarization_threshold
405+
):
406+
return "quasi-TEM"
407+
elif mean_TE_frac >= self.monitor.mode_spec.tem_polarization_threshold:
408+
return "TE"
409+
elif mean_TM_frac >= self.monitor.mode_spec.tem_polarization_threshold:
410+
return "TM"
411+
else:
412+
return "Hybrid"
413+
245414
def _group_index_post_process(self, frequency_step: float) -> Self:
246415
"""Calculate group index and remove added frequencies used only for this calculation.
247416

tidy3d/components/microwave/mode_spec.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
from tidy3d.constants import fp_eps
2121
from tidy3d.exceptions import SetupError
2222

23+
TEM_POLARIZATION_THRESHOLD = 0.995
24+
QTEM_POLARIZATION_THRESHOLD = 0.95
25+
2326

2427
class MicrowaveModeSpec(AbstractModeSpec, MicrowaveBaseModel):
2528
"""
@@ -64,6 +67,27 @@ class MicrowaveModeSpec(AbstractModeSpec, MicrowaveBaseModel):
6467
"ignored for the associated mode.",
6568
)
6669

70+
tem_polarization_threshold: float = pd.Field(
71+
TEM_POLARIZATION_THRESHOLD,
72+
gt=0.0,
73+
le=1.0,
74+
title="TEM Polarization Threshold",
75+
description="Threshold for classifying modes as TEM, TE, or TM based on mean TE/TM fraction "
76+
"across frequencies. A mode is classified as TEM if both mean TE and TM fractions are greater "
77+
"than or equal to this threshold. Similarly, a mode is classified as TE (or TM) if the mean TE "
78+
"(or TM) fraction is greater than or equal to this threshold.",
79+
)
80+
81+
qtem_polarization_threshold: float = pd.Field(
82+
QTEM_POLARIZATION_THRESHOLD,
83+
gt=0.0,
84+
le=1.0,
85+
title="Quasi-TEM Polarization Threshold",
86+
description="Threshold for classifying modes as quasi-TEM based on TE/TM fraction at the lowest "
87+
"frequency. A mode is classified as quasi-TEM if both TE and TM fractions at the lowest frequency "
88+
"are greater than or equal to this threshold.",
89+
)
90+
6791
@cached_property
6892
def _impedance_specs_as_tuple(self) -> tuple[Optional[ImpedanceSpecType]]:
6993
"""Gets the impedance_specs field converted to a tuple."""

tidy3d/components/types/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
LengthUnit,
4242
LumpDistType,
4343
MatrixReal4x4,
44+
ModeClassification,
4445
ModeSolverType,
4546
Numpy,
4647
ObsGridArray,
@@ -109,6 +110,7 @@
109110
"LengthUnit",
110111
"LumpDistType",
111112
"MatrixReal4x4",
113+
"ModeClassification",
112114
"ModeSolverType",
113115
"Numpy",
114116
"ObsGridArray",

tidy3d/components/types/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ def __modify_schema__(cls, field_schema) -> None:
245245

246246
ModeSolverType = Literal["tensorial", "diagonal"]
247247
EpsSpecType = Literal["diagonal", "tensorial_real", "tensorial_complex"]
248+
ModeClassification = Literal["TEM", "quasi-TEM", "TE", "TM", "Hybrid"]
248249

249250
""" mode tracking """
250251

0 commit comments

Comments
 (0)