diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ecb3bdd65..397e7ada2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Various types, e.g. different `Simulation` or `SimulationData` sub-classes, can be loaded from file directly with `Tidy3dBaseModel.from_file()`. - 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. - Added `smoothed_projection` for topology optimization of completely binarized designs. +- 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`. ### Breaking Changes - Edge singularity correction at PEC and lossy metal edges defaults to `True`. diff --git a/schemas/EMESimulation.json b/schemas/EMESimulation.json index 4abe4c086e..2891f92347 100644 --- a/schemas/EMESimulation.json +++ b/schemas/EMESimulation.json @@ -8152,6 +8152,12 @@ ], "type": "string" }, + "qtem_polarization_threshold": { + "default": 0.95, + "exclusiveMinimum": 0.0, + "maximum": 1.0, + "type": "number" + }, "sort_spec": { "allOf": [ { @@ -8174,6 +8180,12 @@ "exclusiveMinimum": 0, "type": "number" }, + "tem_polarization_threshold": { + "default": 0.995, + "exclusiveMinimum": 0.0, + "maximum": 1.0, + "type": "number" + }, "track_freq": { "enum": [ "central", diff --git a/schemas/ModeSimulation.json b/schemas/ModeSimulation.json index 2ea7653555..1e82a69a1d 100644 --- a/schemas/ModeSimulation.json +++ b/schemas/ModeSimulation.json @@ -7399,6 +7399,12 @@ ], "type": "string" }, + "qtem_polarization_threshold": { + "default": 0.95, + "exclusiveMinimum": 0.0, + "maximum": 1.0, + "type": "number" + }, "sort_spec": { "allOf": [ { @@ -7421,6 +7427,12 @@ "exclusiveMinimum": 0, "type": "number" }, + "tem_polarization_threshold": { + "default": 0.995, + "exclusiveMinimum": 0.0, + "maximum": 1.0, + "type": "number" + }, "track_freq": { "enum": [ "central", diff --git a/schemas/Simulation.json b/schemas/Simulation.json index b3f10c60c7..dfd7daa692 100644 --- a/schemas/Simulation.json +++ b/schemas/Simulation.json @@ -11381,6 +11381,12 @@ ], "type": "string" }, + "qtem_polarization_threshold": { + "default": 0.95, + "exclusiveMinimum": 0.0, + "maximum": 1.0, + "type": "number" + }, "sort_spec": { "allOf": [ { @@ -11403,6 +11409,12 @@ "exclusiveMinimum": 0, "type": "number" }, + "tem_polarization_threshold": { + "default": 0.995, + "exclusiveMinimum": 0.0, + "maximum": 1.0, + "type": "number" + }, "track_freq": { "enum": [ "central", diff --git a/schemas/TerminalComponentModeler.json b/schemas/TerminalComponentModeler.json index 057c37f625..b4883e14bd 100644 --- a/schemas/TerminalComponentModeler.json +++ b/schemas/TerminalComponentModeler.json @@ -11723,6 +11723,12 @@ ], "type": "string" }, + "qtem_polarization_threshold": { + "default": 0.95, + "exclusiveMinimum": 0.0, + "maximum": 1.0, + "type": "number" + }, "sort_spec": { "allOf": [ { @@ -11745,6 +11751,12 @@ "exclusiveMinimum": 0, "type": "number" }, + "tem_polarization_threshold": { + "default": 0.995, + "exclusiveMinimum": 0.0, + "maximum": 1.0, + "type": "number" + }, "track_freq": { "enum": [ "central", diff --git a/tests/test_components/test_microwave.py b/tests/test_components/test_microwave.py index 6da4bb00f0..779d1ade75 100644 --- a/tests/test_components/test_microwave.py +++ b/tests/test_components/test_microwave.py @@ -384,6 +384,7 @@ def make_mw_sim( boundary_spec=boundary_spec, plot_length_units="mm", symmetry=(0, 0, 0), + subpixel=False, ) return sim @@ -1099,16 +1100,15 @@ def test_mode_solver_with_microwave_mode_spec(): num_modes = 3 impedance_specs = td.AutoImpedanceSpec() mode_spec = td.MicrowaveModeSpec( - num_modes=num_modes, - target_neff=2.2, - impedance_specs=impedance_specs, + num_modes=num_modes, target_neff=2.2, impedance_specs=impedance_specs ) + freqs = (1e9, 5e9, 10e9) mms = ModeSolver( simulation=stripline_sim, plane=plane, mode_spec=mode_spec, colocate=False, - freqs=[1e9, 5e9, 10e9], + freqs=freqs, ) # _, ax = plt.subplots(1, 1, tight_layout=True, figsize=(15, 15)) @@ -1145,6 +1145,50 @@ 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 RF-specific mode characteristics + e_r = 4.4 + k0 = 1e6 * 2 * np.pi * np.array(freqs) / td.C_0 + n_eff = np.sqrt(e_r) + # 1. Mode classification (stripline should support TEM mode) + assert mms_data.mode_classifications[0] == "TEM", ( + f"Expected TEM mode for stripline, got {mms_data.mode_classifications[0]}" + ) + + assert np.allclose(mms_data.effective_relative_permittivity.sel(mode_index=0).real, e_r) + assert np.allclose( + mms_data.effective_relative_permittivity.sel(mode_index=0).imag, 0.0, atol=1e-6 + ) + + # Attenuation constant (nearly zero for lossless line) + alpha = mms_data.alpha.sel(mode_index=0) + assert np.allclose(alpha, 0.0, atol=1e-6) + + # Phase constant (positive, increases with frequency) + beta = mms_data.beta.sel(mode_index=0) + assert np.allclose(beta, k0 * n_eff) + + # Propagation constant (gamma = -alpha + j*beta) + gamma = mms_data.gamma.sel(mode_index=0) + assert np.allclose(gamma.real, 0.0, atol=1e-6) + assert np.allclose(gamma.imag, k0 * n_eff) + + # Phase velocity (v_p ~ c/n_eff) + v_p = mms_data.phase_velocity.sel(mode_index=0) + expected_v_p = td.C_0 * 1e-6 / n_eff + assert np.allclose(v_p, expected_v_p, rtol=1e-6) + + # Wave impedance (should be positive and physically reasonable) + Z_wave = mms_data.wave_impedance.sel(mode_index=0) + assert np.allclose(Z_wave.real, td.ETA_0 / n_eff, rtol=1e-4) + assert np.allclose(Z_wave.imag, 0.0, atol=1e-6) + + # Distance for 40dB (very large for low-loss line) + d_40dB = mms_data.distance_40dB.sel(mode_index=0) + assert np.all(d_40dB > 100) + + with AssertLogLevel("WARNING", contains_str="The 'group_velocity' was not computed."): + mms_data.group_velocity + # 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 @@ -1207,6 +1251,7 @@ def test_mode_solver_with_microwave_group_index(): # Verify that group index was calculated assert mms_data.n_group is not None, "Group index should be calculated" + assert mms_data.group_velocity is not None, "Group velocity should be calculated" # Verify that transmission line data exists assert mms_data.transmission_line_data is not None, "Transmission line data should exist" diff --git a/tidy3d/components/microwave/data/data_array.py b/tidy3d/components/microwave/data/data_array.py new file mode 100644 index 0000000000..c5fd6a358a --- /dev/null +++ b/tidy3d/components/microwave/data/data_array.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from tidy3d.components.data.data_array import FreqModeDataArray +from tidy3d.constants import NEPERPERMETER, PERMETER, RADPERMETER, VELOCITY_SI + + +class PropagationConstantArray(FreqModeDataArray): + __slots__ = () + _data_attrs = {"units": PERMETER, "long_name": "propagation constant"} + + +class PhaseConstantArray(FreqModeDataArray): + __slots__ = () + _data_attrs = {"units": RADPERMETER, "long_name": "phase constant"} + + +class AttenuationConstantArray(FreqModeDataArray): + __slots__ = () + _data_attrs = {"units": NEPERPERMETER, "long_name": "attenuation constant"} + + +class PhaseVelocityArray(FreqModeDataArray): + __slots__ = () + _data_attrs = {"units": VELOCITY_SI, "long_name": "phase velocity"} + + +class GroupVelocityArray(FreqModeDataArray): + __slots__ = () + _data_attrs = {"units": VELOCITY_SI, "long_name": "group velocity"} diff --git a/tidy3d/components/microwave/data/monitor_data.py b/tidy3d/components/microwave/data/monitor_data.py index 41c216ad54..cfccc9bcb9 100644 --- a/tidy3d/components/microwave/data/monitor_data.py +++ b/tidy3d/components/microwave/data/monitor_data.py @@ -6,16 +6,31 @@ from typing import Literal, Optional +import numpy as np import pydantic.v1 as pd import xarray as xr from typing_extensions import Self -from tidy3d.components.data.data_array import FieldProjectionAngleDataArray, FreqDataArray +from tidy3d.components.data.data_array import ( + FieldProjectionAngleDataArray, + FreqDataArray, + FreqModeDataArray, + ImpedanceFreqModeDataArray, +) from tidy3d.components.data.monitor_data import DirectivityData, ModeData, ModeSolverData from tidy3d.components.microwave.base import MicrowaveBaseModel +from tidy3d.components.microwave.data.data_array import ( + AttenuationConstantArray, + GroupVelocityArray, + PhaseConstantArray, + PhaseVelocityArray, + PropagationConstantArray, +) from tidy3d.components.microwave.data.dataset import TransmissionLineDataset from tidy3d.components.microwave.monitor import MicrowaveModeMonitor, MicrowaveModeSolverMonitor -from tidy3d.components.types import FreqArray, PolarizationBasis +from tidy3d.components.types import FreqArray, ModeClassification, PolarizationBasis +from tidy3d.constants import C_0 +from tidy3d.log import log class AntennaMetricsData(DirectivityData, MicrowaveBaseModel): @@ -242,6 +257,164 @@ def modes_info(self) -> xr.Dataset: super_info["Im(Z0)"] = self.transmission_line_data.Z0.imag return super_info + @property + def mode_classifications(self) -> list[ModeClassification]: + """List of mode classifications (TEM, quasi-TEM, TE, TM, or Hybrid) for each mode.""" + return [self._classify_mode(mode_index) for mode_index in self.n_complex.mode_index] + + @property + def free_space_wavenumber(self) -> FreqDataArray: + """The free space wavenumber (k_0) in rad/m.""" + freqs = self.n_complex.f.values + C_0_meters = C_0 * 1e-6 + return FreqDataArray(2 * np.pi * freqs / C_0_meters, coords={"f": freqs}) + + @property + def gamma(self) -> PropagationConstantArray: + r"""The propagation constant with SI units. + + In the physics convention, where time-harmonic fields evolve with :math:`e^{-j\omega t}`, + a wave propagating in the +z direction varies as: + + .. math:: + + E(z) = E_0 e^{\gamma z} = E_0 e^{-\alpha z} e^{j\beta z} + + where :math:`\gamma = -\alpha + j\beta`. + """ + data = 1j * self.n_complex * self.free_space_wavenumber + return PropagationConstantArray(data, coords=self.n_complex.coords) + + @property + def alpha(self) -> AttenuationConstantArray: + r"""The attenuation constant (real part of :math:`-\gamma`). + + Causes exponential decay of the field amplitude: + + .. math:: + + E(z) = E_0 e^{-\alpha z} e^{j\beta z} + + Units: Nepers/meter (Np/m). + """ + return -self.gamma.real + + @property + def beta(self) -> PhaseConstantArray: + r"""The phase constant (imaginary part of :math:`\gamma`). + + Determines the phase variation of the field: + + .. math:: + + E(z) = E_0 e^{-\alpha z} e^{j\beta z} + + Units: radians/meter (rad/m). + """ + return self.gamma.imag + + @property + def distance_40dB(self) -> FreqModeDataArray: + r"""Distance at which the field amplitude drops by 40 dB. + + For a lossy transmission line, this is the distance where the signal + attenuates by 40 dB: + + .. math:: + + d_{40\text{dB}} = \frac{40\,\text{dB}}{20 \log_{10}(e) \cdot \alpha} = \frac{40}{8.686 \cdot \alpha} + + where :math:`\alpha` is the attenuation constant in Nepers/meter. + + Units: meters. + """ + # Convert attenuation from Nepers/m to dB/m: dB/m = 20*log10(e)*Np/m ≈ 8.686*Np/m + # Then: distance_40dB = 40 dB / (attenuation in dB/m) + attenuation_dB_per_m = 20 * np.log10(np.e) * self.alpha + distance_meters = 40 / attenuation_dB_per_m + return FreqModeDataArray(distance_meters.values, coords=self.alpha.coords) + + @property + def effective_relative_permittivity(self) -> FreqModeDataArray: + """Effective relative permittivity (real part of n_eff²).""" + e_r_complex = self.n_complex * self.n_complex + return FreqModeDataArray(e_r_complex.values, coords=self.n_complex.coords) + + @property + def phase_velocity(self) -> PhaseVelocityArray: + """Phase velocity (v_p = c/n_eff) in m/s.""" + C_0_meters = C_0 * 1e-6 + v_p = C_0_meters / self.n_eff + return PhaseVelocityArray(v_p.values, coords=self.n_eff.coords) + + @property + def group_velocity(self) -> Optional[GroupVelocityArray]: + """Group velocity (v_g = c/n_group) in m/s.""" + if self.n_group_raw is None: + log.warning( + "The 'group_velocity' was not computed. To calculate 'group_velocity' index, pass " + "'group_index_step = True' in the 'MicrowaveModeSpec'.", + log_once=True, + ) + return None + C_0_meters = C_0 * 1e-6 + v_g = C_0_meters / self.n_group + return GroupVelocityArray(v_g.values, coords=self.n_eff.coords) + + @property + def wave_impedance(self) -> ImpedanceFreqModeDataArray: + r"""Compute the wave impedance associated with the waveguide mode. + The wave impedance is defined as: + + .. math:: + + Z_{\rm wave} = \frac{\int |E_t|^2 \, {\rm d}S}{2 P}. + + where :math:`E_t` is the transverse electric field and :math:`P` is the complex power flow. + """ + self._check_fields_stored(["Ex", "Ey", "Ez", "Hx", "Hy", "Hz"]) + + tan_fields = self._colocated_tangential_fields + dim1, dim2 = self._tangential_dims + e1 = tan_fields["E" + dim1] + e2 = tan_fields["E" + dim2] + diff_area = self._diff_area + field_int = [np.abs(e_field) ** 2 for e_field in [e1, e2]] + tangential_intensity = (diff_area * (field_int[0] + field_int[1])).sum( + dim=self._tangential_dims + ) + direction = self.monitor.store_fields_direction + P = self.complex_flux if direction == "+" else -self.complex_flux + Z_wave = tangential_intensity / P / 2 + return ImpedanceFreqModeDataArray(Z_wave.values, coords=self.flux.coords) + + def _classify_mode(self, mode_index: int) -> ModeClassification: + """Classify mode as TEM, quasi-TEM, TE, TM, or Hybrid based on TE/TM fractions.""" + # Make quasi-TEM classification choice based on lowest frequency available + min_f_idx = self.wg_TE_fraction.f.argmin() + low_f_TE_frac = self.wg_TE_fraction.sel(mode_index=mode_index).isel(f=min_f_idx).values + low_f_TM_frac = self.wg_TM_fraction.sel(mode_index=mode_index).isel(f=min_f_idx).values + # Otherwise we use the average value of the fraction across frequencies + mean_TE_frac = self.wg_TE_fraction.sel(mode_index=mode_index).mean().values + mean_TM_frac = self.wg_TM_fraction.sel(mode_index=mode_index).mean().values + + if ( + mean_TE_frac >= self.monitor.mode_spec.tem_polarization_threshold + and mean_TM_frac >= self.monitor.mode_spec.tem_polarization_threshold + ): + return "TEM" + elif ( + low_f_TE_frac >= self.monitor.mode_spec.qtem_polarization_threshold + and low_f_TM_frac >= self.monitor.mode_spec.qtem_polarization_threshold + ): + return "quasi-TEM" + elif mean_TE_frac >= self.monitor.mode_spec.tem_polarization_threshold: + return "TE" + elif mean_TM_frac >= self.monitor.mode_spec.tem_polarization_threshold: + return "TM" + else: + return "Hybrid" + def _group_index_post_process(self, frequency_step: float) -> Self: """Calculate group index and remove added frequencies used only for this calculation. diff --git a/tidy3d/components/microwave/mode_spec.py b/tidy3d/components/microwave/mode_spec.py index 2f95e406de..579f080500 100644 --- a/tidy3d/components/microwave/mode_spec.py +++ b/tidy3d/components/microwave/mode_spec.py @@ -20,6 +20,9 @@ from tidy3d.constants import fp_eps from tidy3d.exceptions import SetupError +TEM_POLARIZATION_THRESHOLD = 0.995 +QTEM_POLARIZATION_THRESHOLD = 0.95 + class MicrowaveModeSpec(AbstractModeSpec, MicrowaveBaseModel): """ @@ -64,6 +67,27 @@ class MicrowaveModeSpec(AbstractModeSpec, MicrowaveBaseModel): "ignored for the associated mode.", ) + tem_polarization_threshold: float = pd.Field( + TEM_POLARIZATION_THRESHOLD, + gt=0.0, + le=1.0, + title="TEM Polarization Threshold", + description="Threshold for classifying modes as TEM, TE, or TM based on mean TE/TM fraction " + "across frequencies. A mode is classified as TEM if both mean TE and TM fractions are greater " + "than or equal to this threshold. Similarly, a mode is classified as TE (or TM) if the mean TE " + "(or TM) fraction is greater than or equal to this threshold.", + ) + + qtem_polarization_threshold: float = pd.Field( + QTEM_POLARIZATION_THRESHOLD, + gt=0.0, + le=1.0, + title="Quasi-TEM Polarization Threshold", + description="Threshold for classifying modes as quasi-TEM based on TE/TM fraction at the lowest " + "frequency. A mode is classified as quasi-TEM if both TE and TM fractions at the lowest frequency " + "are greater than or equal to this threshold.", + ) + @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/types/__init__.py b/tidy3d/components/types/__init__.py index 90dd0dc84d..0d7c70e94c 100644 --- a/tidy3d/components/types/__init__.py +++ b/tidy3d/components/types/__init__.py @@ -41,6 +41,7 @@ LengthUnit, LumpDistType, MatrixReal4x4, + ModeClassification, ModeSolverType, Numpy, ObsGridArray, @@ -109,6 +110,7 @@ "LengthUnit", "LumpDistType", "MatrixReal4x4", + "ModeClassification", "ModeSolverType", "Numpy", "ObsGridArray", diff --git a/tidy3d/components/types/base.py b/tidy3d/components/types/base.py index cfe1f03615..b49e5d7086 100644 --- a/tidy3d/components/types/base.py +++ b/tidy3d/components/types/base.py @@ -245,6 +245,7 @@ def __modify_schema__(cls, field_schema) -> None: ModeSolverType = Literal["tensorial", "diagonal"] EpsSpecType = Literal["diagonal", "tensorial_real", "tensorial_complex"] +ModeClassification = Literal["TEM", "quasi-TEM", "TE", "TM", "Hybrid"] """ mode tracking """ diff --git a/tidy3d/constants.py b/tidy3d/constants.py index c0b6027d6e..81b168cad5 100644 --- a/tidy3d/constants.py +++ b/tidy3d/constants.py @@ -111,6 +111,11 @@ SI unit of length. """ +PERMETER = "1/m" +""" +SI unit of inverse length. +""" + MICROMETER = "um" """ One millionth (10^-6) of a meter. @@ -146,6 +151,17 @@ One radian per second. """ +RADPERMETER = "rad/m" +""" +One radian per meter. +""" + +NEPERPERMETER = "Np/m" +""" +SI unit for attenuation constant. +""" + + ELECTRON_VOLT = "eV" """ Unit of energy. @@ -251,6 +267,11 @@ Inverse Kelvin. """ +VELOCITY_SI = "m/s" +""" +SI unit of velocity +""" + ACCELERATION = "um/s^2" """ Acceleration unit.