From ca98bd2c75c0cc4ef92ff0c82118cbaaee6d045c Mon Sep 17 00:00:00 2001 From: dmarek Date: Mon, 20 Jan 2025 13:53:09 -0500 Subject: [PATCH] adding methods to DirectivityData to help compute antenna gain add support for calculating antenna gain and related efficiencies revamped calcs in DirectivityMonitor allowing for different polarization bases added functionality for combining results from multiple port excitations reorganize smatrix plugin by adding a data module --- CHANGELOG.md | 4 + docs/api/index.rst | 2 + docs/api/microwave/index.rst | 10 + docs/api/microwave/output_data.rst | 10 + docs/api/plugins/smatrix.rst | 1 + tests/test_components/test_microwave.py | 48 ++ tests/test_data/test_data_arrays.py | 4 +- tests/test_data/test_monitor_data.py | 148 +++++- .../test_terminal_component_modeler.py | 211 ++++++++- tests/utils.py | 26 ++ tidy3d/__init__.py | 4 + tidy3d/components/data/monitor_data.py | 421 +++++++++++++++--- tidy3d/components/microwave/data/__init__.py | 0 .../components/microwave/data/monitor_data.py | 173 +++++++ tidy3d/components/types.py | 1 + tidy3d/plugins/smatrix/__init__.py | 7 +- .../smatrix/component_modelers/base.py | 10 + .../smatrix/component_modelers/terminal.py | 367 +++++++++++---- tidy3d/plugins/smatrix/data/__init__.py | 0 tidy3d/plugins/smatrix/data/terminal.py | 43 ++ tidy3d/plugins/smatrix/ports/base_terminal.py | 24 +- 21 files changed, 1336 insertions(+), 178 deletions(-) create mode 100644 docs/api/microwave/index.rst create mode 100644 docs/api/microwave/output_data.rst create mode 100644 tidy3d/components/microwave/data/__init__.py create mode 100644 tidy3d/components/microwave/data/monitor_data.py create mode 100644 tidy3d/plugins/smatrix/data/__init__.py create mode 100644 tidy3d/plugins/smatrix/data/terminal.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a7e1f2c4..6a4ae60b38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `PlaneWaveBeamProfile`, `GaussianBeamProfile` and `AstigmaticGaussianBeamProfile` components to compute field data associated to such beams based on their corresponding analytical expressions, and compute field overlaps with other data. - `num_freqs` argument to the `PlaneWave` source. - Support for running multiple adjoint simulations from a single forward simulation in adjoint pipeline depending on monitor configuration. +- Ability to directly create `DirectivityData` from an `xarrray.Dataset` containing electromagnetic fields, where the flux is calculated by integrating the fields over the surface of a sphere. +- Ability to the `TerminalComponentModeler` that enables the computation of antenna parameters and figures of merit, such as gain, radiation efficiency, and reflection efficiency. When there are multiple ports in the `TerminalComponentModeler`, these antenna parameters may be calculated with user-specified port excitation magnitudes and phases. ### Changed - The coordinate of snapping points in `GridSpec` can take value `None`, so that mesh can be selectively snapped only along certain dimensions. @@ -26,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added warning when a lumped element is not completely within the simulation bounds, since now lumped elements will only have an effect on the `Simulation` when they are completely within the simulation bounds. - Allow `ModeData` to be passed to path integral computations in the `microwave` plugin. - Default number of grid cells refining lumped elements is changed to 1, and some of the generated `MeshOverrideStructure` are replaced by grid snapping points. +- Definition of left- and right-handed circular polarization in `DirectivityData` to follow engineering convention. +- Extended the number of quantities provided by the `DirectivityData`, which now includes more parameters of interest like radiation intensity and gain. In addition, antenna parameters can be decomposed into contributions from individual polarization components according to a specified polarization basis, either `linear` or `circular`. ### Fixed - Make gauge selection for non-converged modes more robust. diff --git a/docs/api/index.rst b/docs/api/index.rst index c50030f491..9001b0574c 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -27,6 +27,7 @@ API |:computer:| heat/index charge/index eme/index + microwave/index plugins/index spice constants @@ -53,6 +54,7 @@ API |:computer:| .. include:: /api/heat/index.rst .. include:: /api/charge/index.rst .. include:: /api/eme/index.rst +.. include:: /api/microwave/index.rst .. include:: /api/plugins/index.rst .. include:: /api/constants.rst .. include:: /api/abstract_base.rst diff --git a/docs/api/microwave/index.rst b/docs/api/microwave/index.rst new file mode 100644 index 0000000000..a87e2a5b5b --- /dev/null +++ b/docs/api/microwave/index.rst @@ -0,0 +1,10 @@ +Microwave |:satellite:| +======================= + +.. toctree:: + :hidden: + + output_data + + +.. include:: /api/microwave/output_data.rst diff --git a/docs/api/microwave/output_data.rst b/docs/api/microwave/output_data.rst new file mode 100644 index 0000000000..2a883858d2 --- /dev/null +++ b/docs/api/microwave/output_data.rst @@ -0,0 +1,10 @@ +.. currentmodule:: tidy3d + +Output Data +----------- + +.. autosummary:: + :toctree: ../_autosummary/ + :template: module.rst + + tidy3d.AntennaMetricsData diff --git a/docs/api/plugins/smatrix.rst b/docs/api/plugins/smatrix.rst index 39114121d4..00761e8748 100644 --- a/docs/api/plugins/smatrix.rst +++ b/docs/api/plugins/smatrix.rst @@ -11,6 +11,7 @@ Scattering Matrix Calculator tidy3d.plugins.smatrix.Port tidy3d.plugins.smatrix.ModalPortDataArray tidy3d.plugins.smatrix.TerminalComponentModeler + tidy3d.plugins.smatrix.PortDataArray tidy3d.plugins.smatrix.TerminalPortDataArray tidy3d.plugins.smatrix.LumpedPort tidy3d.plugins.smatrix.CoaxialLumpedPort diff --git a/tests/test_components/test_microwave.py b/tests/test_components/test_microwave.py index aa98a526a2..901459592e 100644 --- a/tests/test_components/test_microwave.py +++ b/tests/test_components/test_microwave.py @@ -3,6 +3,10 @@ from math import isclose import numpy as np +import pytest +import xarray as xr +from tidy3d.components.data.monitor_data import FreqDataArray +from tidy3d.components.microwave.data.monitor_data import AntennaMetricsData from tidy3d.components.microwave.formulas.circuit_parameters import ( capacitance_colinear_cylindrical_wire_segments, capacitance_rectangular_sheets, @@ -12,6 +16,8 @@ ) from tidy3d.constants import EPSILON_0 +from ..test_data.test_monitor_data import make_directivity_data + def test_inductance_formulas(): """Run the formulas for inductance and compare to precomputed results.""" @@ -49,3 +55,45 @@ def test_capacitance_formulas(): D2 = 0.144 C_ref = np.pi * EPSILON_0 * length / (np.log(length / radius) - 2.303 * D2) assert isclose(C3, C_ref, rel_tol=1e-2) + + +def test_antenna_parameters(): + """Test basic antenna parameters computation and validation.""" + + # Create from random directivity data + directivity_data = make_directivity_data() + f = directivity_data.coords["f"] + power_inc = FreqDataArray(0.8 * np.ones(len(f)), coords={"f": f}) + power_refl = 0.25 * power_inc + antenna_params = AntennaMetricsData.from_directivity_data( + directivity_data, power_inc, power_refl + ) + + # Test that all essential parameters exist and are correct type + assert isinstance(antenna_params.radiation_efficiency, FreqDataArray) + assert isinstance(antenna_params.reflection_efficiency, FreqDataArray) + assert np.allclose(antenna_params.reflection_efficiency, 0.75) + assert isinstance(antenna_params.gain, xr.DataArray) + assert isinstance(antenna_params.realized_gain, xr.DataArray) + + # Test partial gain computations in linear basis + partial_gain_linear = antenna_params.partial_gain(pol_basis="linear") + assert isinstance(partial_gain_linear, xr.Dataset) + assert "Gtheta" in partial_gain_linear + assert "Gphi" in partial_gain_linear + + # Test partial gain computations in circular basis + partial_gain_circular = antenna_params.partial_gain(pol_basis="circular") + assert isinstance(partial_gain_circular, xr.Dataset) + assert "Gright" in partial_gain_circular + assert "Gleft" in partial_gain_circular + + # Test partial realized gain computations in both bases + assert isinstance(antenna_params.partial_realized_gain("linear"), xr.Dataset) + assert isinstance(antenna_params.partial_realized_gain("circular"), xr.Dataset) + + # Test validation of pol_basis parameter + with pytest.raises(ValueError): + antenna_params.partial_gain("invalid") + with pytest.raises(ValueError): + antenna_params.partial_realized_gain("invalid") diff --git a/tests/test_data/test_data_arrays.py b/tests/test_data/test_data_arrays.py index 2e5fd860dd..262d6658a8 100644 --- a/tests/test_data/test_data_arrays.py +++ b/tests/test_data/test_data_arrays.py @@ -43,8 +43,8 @@ TS = np.linspace(0, 1e-12, 4) MODE_INDICES = np.arange(0, 4) DIRECTIONS = ["+", "-"] -PHIS = np.linspace(0, np.pi, 100) -THETAS = np.linspace(0, 2 * np.pi, 100) +PHIS = np.linspace(0, 2 * np.pi, 100) +THETAS = np.linspace(0, np.pi, 100) PD = np.atleast_1d(4000) FIELD_MONITOR = td.FieldMonitor(size=SIZE_3D, fields=FIELDS, name="field", freqs=FREQS) diff --git a/tests/test_data/test_monitor_data.py b/tests/test_data/test_monitor_data.py index 7f33468e77..e978058c87 100644 --- a/tests/test_data/test_monitor_data.py +++ b/tests/test_data/test_monitor_data.py @@ -5,7 +5,11 @@ import pydantic.v1 as pydantic import pytest import tidy3d as td -from tidy3d.components.data.data_array import FreqModeDataArray +import xarray as xr +from tidy3d.components.data.data_array import ( + FreqDataArray, + FreqModeDataArray, +) from tidy3d.components.data.monitor_data import ( DiffractionData, DirectivityData, @@ -185,10 +189,15 @@ def make_flux_data(): return FluxData(monitor=FLUX_MONITOR, flux=FLUX.copy()) -def make_directivity_data(): +def make_directivity_data(planar_monitor: bool = False): data = make_far_field_data_array() + monitor = DIRECTIVITY_MONITOR + if planar_monitor: + size = list(DIRECTIVITY_MONITOR.size) + size[1] = 0 + monitor = DIRECTIVITY_MONITOR.updated_copy(size=size) return DirectivityData( - monitor=DIRECTIVITY_MONITOR, + monitor=monitor, flux=FLUX.copy(), Er=data, Etheta=data, @@ -196,7 +205,38 @@ def make_directivity_data(): Hr=data, Htheta=data, Hphi=data, + projection_surfaces=monitor.projection_surfaces, + ) + + +def make_field_dataset_using_power_density( + values: np.ndarray, theta: np.ndarray, phi: np.ndarray, freqs: np.ndarray, r_proj: np.ndarray +): + """Helper function to create ``DirectivityMonitor`` and field dataset with a desired power density.""" + monitor = td.DirectivityMonitor( + size=(2, 2, 2), + center=(0, 0, 0), + freqs=freqs, + name="proj_monitor", + far_field_approx=True, + proj_distance=r_proj, + theta=theta, + phi=phi, + ) + + coords = dict(r=r_proj, theta=theta, phi=phi, f=freqs) + field = td.FieldProjectionAngleDataArray(values, coords=coords) + + field_components = dict( + Er=field, + Etheta=field, + Ephi=field, + Hr=field, + Htheta=-1.0 * field, + Hphi=field, ) + field_dataset = xr.Dataset(field_components) + return monitor, field_dataset def make_flux_time_data(): @@ -337,12 +377,102 @@ def test_flux_time_data(): _ = data.flux -def test_directivity_data(): - data = make_directivity_data() - _ = data.directivity - _ = data.axial_ratio - _ = data.left_polarization - _ = data.right_polarization +@pytest.mark.parametrize("planar_monitor", [False, True]) +def test_directivity_data(planar_monitor): + data = make_directivity_data(planar_monitor) + _ = data.flux + f = data.flux.f.values + # make some dummy data to represent power supplied to antenna + power_in = FreqDataArray(np.abs(np.random.random(size=np.shape(f))), coords=dict(f=f)) + assert isinstance(data.partial_radiation_intensity(), xr.Dataset) + assert isinstance(data.radiation_intensity, xr.DataArray) + assert isinstance(data.partial_directivity(), xr.Dataset) + assert isinstance(data.directivity, xr.DataArray) + + assert isinstance(data.calc_partial_gain(power_in), xr.Dataset) + assert isinstance(data.calc_gain(power_in), xr.DataArray) + assert isinstance(data.axial_ratio, xr.DataArray) + assert isinstance(data.left_polarization, xr.DataArray) + assert isinstance(data.right_polarization, xr.DataArray) + + # Test computations using the circular polarization basis + pol_basis = "circular" + assert isinstance(data.fields_circular_polarization, xr.Dataset) + assert isinstance(data.partial_radiation_intensity(pol_basis), xr.Dataset) + assert isinstance(data.partial_directivity(pol_basis), xr.Dataset) + assert isinstance(data.calc_partial_gain(power_in=power_in, pol_basis=pol_basis), xr.Dataset) + + # Test raise exception when pol_basis is wrong + with pytest.raises(ValueError): + data.partial_radiation_intensity("invalid") + with pytest.raises(ValueError): + data.partial_directivity("invalid") + with pytest.raises(ValueError): + data.calc_partial_gain(power_in, "invalid") + # Test helpers to slice data along a constant phi + DirectivityData.get_phi_slice(data.Etheta, phi=0) + DirectivityData.get_phi_slice(data.Etheta, phi=np.pi, symmetric=True) + + +def test_directivity_data_from_projected_fields(): + """Test DirectivityData is constructed properly and integration of uniform fields over + spherical surface matches analytic value. Also test validation of angle sampling.""" + + freqs = np.array([1e9, 10e9]) + r_proj = np.array([1.0]) + # Test invalid theta range + theta = np.linspace(0, np.pi / 2, 20) # Missing half sphere + phi = np.linspace(0, 2 * np.pi, 40) + values = np.ones((len(r_proj), len(theta), len(phi), len(freqs)), dtype=complex) + monitor, proj_angle_data = make_field_dataset_using_power_density( + values, theta, phi, freqs, r_proj + ) + with pytest.raises(ValueError, match="Chosen limits for `theta` are not appropriate"): + dir_data = td.DirectivityData.from_spherical_field_dataset(monitor, proj_angle_data) + + # Test invalid phi range + theta = np.linspace(0, np.pi, 20) + phi = np.linspace(0, np.pi, 40) # Missing half sphere + values = np.ones((len(r_proj), len(theta), len(phi), len(freqs)), dtype=complex) + monitor, proj_angle_data = make_field_dataset_using_power_density( + values, theta, phi, freqs, r_proj + ) + with pytest.raises(ValueError, match="Chosen limits for `phi` are not appropriate"): + dir_data = td.DirectivityData.from_spherical_field_dataset(monitor, proj_angle_data) + + # Test too coarse sampling + theta = np.linspace(0, np.pi, 5) # Too few points + phi = np.linspace(0, 2 * np.pi, 40) + values = np.ones((len(r_proj), len(theta), len(phi), len(freqs)), dtype=complex) + monitor, proj_angle_data = make_field_dataset_using_power_density( + values, theta, phi, freqs, r_proj + ) + with pytest.raises(ValueError, match="There are not enough sampling points"): + dir_data = td.DirectivityData.from_spherical_field_dataset(monitor, proj_angle_data) + + # Test unsorted + theta = np.linspace(0, np.pi, 20)[::-1] + phi = np.linspace(0, 2 * np.pi, 40) + values = np.ones((len(r_proj), len(theta), len(phi), len(freqs)), dtype=complex) + monitor, proj_angle_data = make_field_dataset_using_power_density( + values, theta, phi, freqs, r_proj + ) + with pytest.raises(ValueError, match="theta was not provided as a sorted array."): + dir_data = td.DirectivityData.from_spherical_field_dataset(monitor, proj_angle_data) + + # Test success case with proper sampling + theta = np.linspace(0, np.pi, 20) + phi = np.linspace(0, 2 * np.pi, 40) + values = np.ones((len(r_proj), len(theta), len(phi), len(freqs)), dtype=complex) + monitor, proj_angle_data = make_field_dataset_using_power_density( + values, theta, phi, freqs, r_proj + ) + dir_data = td.DirectivityData.from_spherical_field_dataset(monitor, proj_angle_data) + + # Flux should correspond with the surface area of a sphere + flux_values = dir_data.flux.values + # Check against analytical value with 1% tolerance + assert np.allclose(flux_values, 4 * np.pi, rtol=1e-2) def test_diffraction_data(): diff --git a/tests/test_plugins/smatrix/test_terminal_component_modeler.py b/tests/test_plugins/smatrix/test_terminal_component_modeler.py index 3f5f67aec8..9a9ce72ef3 100644 --- a/tests/test_plugins/smatrix/test_terminal_component_modeler.py +++ b/tests/test_plugins/smatrix/test_terminal_component_modeler.py @@ -1,10 +1,11 @@ import matplotlib.pyplot as plt import numpy as np -import pydantic.v1 as pydantic +import pydantic.v1 as pd import pytest import tidy3d as td +import xarray as xr from tidy3d.components.data.data_array import FreqDataArray -from tidy3d.exceptions import SetupError, Tidy3dKeyError +from tidy3d.exceptions import SetupError, Tidy3dError, Tidy3dKeyError from tidy3d.plugins.microwave import CustomCurrentIntegral2D, VoltageIntegralAxisAligned from tidy3d.plugins.smatrix import ( AbstractComponentModeler, @@ -73,10 +74,28 @@ def test_validate_no_sources(tmp_path): source_time=td.GaussianPulse(freq0=2e14, fwidth=1e14), polarization="Ex" ) sim_w_source = modeler.simulation.copy(update=dict(sources=(source,))) - with pytest.raises(pydantic.ValidationError): + with pytest.raises(pd.ValidationError): _ = modeler.copy(update=dict(simulation=sim_w_source)) +def test_validate_3D_sim(tmp_path): + modeler = make_component_modeler(planar_pec=False, path_dir=str(tmp_path)) + sim = td.Simulation( + size=(10e3, 10e3, 0), + sources=[], + monitors=[], + grid_spec=td.GridSpec.uniform(dl=1e3), + boundary_spec=td.BoundarySpec( + x=td.Boundary.pml(), + y=td.Boundary.pml(), + z=td.Boundary.periodic(), + ), + run_time=1e-10, + ) + with pytest.raises(pd.ValidationError): + _ = modeler.updated_copy(simulation=sim) + + def test_no_port(tmp_path): modeler = make_component_modeler(planar_pec=True, path_dir=str(tmp_path)) _ = modeler.ports @@ -224,7 +243,7 @@ def test_coarse_grid_at_port(monkeypatch, tmp_path): def test_validate_port_voltage_axis(): - with pytest.raises(pydantic.ValidationError): + with pytest.raises(pd.ValidationError): LumpedPort(center=(0, 0, 0), size=(0, 1, 2), voltage_axis=0, impedance=50) @@ -271,7 +290,7 @@ def test_coarse_grid_at_coaxial_port(monkeypatch, tmp_path): def test_validate_coaxial_center_not_inf(): - with pytest.raises(pydantic.ValidationError): + with pytest.raises(pd.ValidationError): CoaxialLumpedPort( center=(td.inf, 0, 0), outer_diameter=8, @@ -285,7 +304,7 @@ def test_validate_coaxial_center_not_inf(): def test_validate_coaxial_port_diameters(): - with pytest.raises(pydantic.ValidationError): + with pytest.raises(pd.ValidationError): CoaxialLumpedPort( center=(0, 0, 0), outer_diameter=1, @@ -487,7 +506,7 @@ def test_wave_port_path_integral_validation(): current_integral=custom_current_path, ) - with pytest.raises(pydantic.ValidationError): + with pytest.raises(pd.ValidationError): _ = WavePort( center=center_port, size=size_port, @@ -499,7 +518,7 @@ def test_wave_port_path_integral_validation(): ) voltage_path = voltage_path.updated_copy(size=(4, 0, 0)) - with pytest.raises(pydantic.ValidationError): + with pytest.raises(pd.ValidationError): _ = WavePort( center=center_port, size=size_port, @@ -513,7 +532,7 @@ def test_wave_port_path_integral_validation(): custom_current_path = CustomCurrentIntegral2D.from_circular_path( center=center_port, radius=3, num_points=21, normal_axis=2, clockwise=False ) - with pytest.raises(pydantic.ValidationError): + with pytest.raises(pd.ValidationError): _ = WavePort( center=center_port, size=size_port, @@ -580,5 +599,177 @@ def test_wave_port_validate_current_integral(tmp_path): modeler = make_coaxial_component_modeler( path_dir=str(tmp_path), port_types=(WavePort, WavePort) ) - with pytest.raises(pydantic.ValidationError): + with pytest.raises(pd.ValidationError): _ = modeler.updated_copy(direction="-", path="ports/0/") + + +def test_port_impedance_check(): + """ "Tests the impedance consistency check.""" + Z_numpy = np.ones((50, 3)) + Z_numpy[:, 1] = -1.0 + # All ok if same sign for every frequency + TerminalComponentModeler._check_port_impedance_sign(Z_numpy) + Z_numpy[25, 1] = 1.0 + # Change of sign is unexpected + with pytest.raises(Tidy3dError): + TerminalComponentModeler._check_port_impedance_sign(Z_numpy) + + +def test_antenna_helpers(monkeypatch, tmp_path): + """Test monitor data normalization and combination helpers for antenna parameters.""" + # Setup basic modeler with radiation monitor + modeler = make_component_modeler(False, path_dir=str(tmp_path)) + sim = modeler.simulation + theta = np.linspace(0, np.pi, 40) + phi = np.linspace(0, 2 * np.pi, 80) + radiation_monitor = td.DirectivityMonitor( + size=sim.size, + center=sim.center, + freqs=modeler.freqs, + name="antenna_monitor", + far_field_approx=True, + proj_distance=max(sim.size) * 100, + theta=theta, + phi=phi, + ) + modeler = modeler.updated_copy(radiation_monitors=[radiation_monitor]) + + # Run simulation to get data + _ = run_component_modeler(monkeypatch, modeler) + batch_data = modeler.batch_data + sim_data = batch_data[modeler._task_name(modeler.ports[0])] + rad_mon_data = sim_data[radiation_monitor.name] + + # Test monitor helper + found_mon = modeler.get_radiation_monitor_by_name(radiation_monitor.name) + assert found_mon == radiation_monitor + with pytest.raises(Tidy3dKeyError): + modeler.get_radiation_monitor_by_name("invalid") + + # Test monitor data normalization with different amplitude types + a_array = FreqDataArray(np.ones(len(modeler.freqs)), dict(f=modeler.freqs)) + normalized_data_array = modeler._monitor_data_at_port_amplitude( + modeler.ports[0], sim_data, rad_mon_data, a_array + ) + normalized_data_const = modeler._monitor_data_at_port_amplitude( + modeler.ports[0], sim_data, rad_mon_data, 1.0 + ) + assert isinstance(normalized_data_array, td.DirectivityData) + assert isinstance(normalized_data_const, td.DirectivityData) + + # Test combining monitor data + combined_data = normalized_data_array + normalized_data_const + assert isinstance(combined_data, td.DirectivityData) + + # Test power wave amplitude computation + a, b = modeler.compute_power_wave_amplitudes_at_each_port( + modeler.port_reference_impedances, sim_data + ) + assert isinstance(a, PortDataArray) + assert isinstance(b, PortDataArray) + + +def test_antenna_parameters(monkeypatch, tmp_path): + """Test basic antenna parameters computation and validation.""" + # Setup modeler with radiation monitor + modeler = make_component_modeler(False, path_dir=str(tmp_path)) + sim = modeler.simulation + theta = np.linspace(0, np.pi, 101) + phi = np.linspace(0, 2 * np.pi, 201) + proj_distance = max(sim.size) * 100 + # First test validation of the radiation monitors + # The frequencies should be a subset of the freqs set in the TerminalComponentModeler + freqs = [3.14e13] + radiation_monitor = td.DirectivityMonitor( + size=sim.size, + center=sim.center, + freqs=freqs, + name="antenna_monitor", + far_field_approx=True, + proj_distance=proj_distance, + theta=theta, + phi=phi, + ) + with pytest.raises(pd.ValidationError): + modeler = modeler.updated_copy(radiation_monitors=[radiation_monitor]) + + radiation_monitor = radiation_monitor.updated_copy(freqs=modeler.freqs) + modeler = modeler.updated_copy(radiation_monitors=[radiation_monitor]) + + # Run simulation and get antenna parameters + _ = run_component_modeler(monkeypatch, modeler) + antenna_params = modeler.get_antenna_metrics_data() + + # Test that all essential parameters exist and are correct type + assert isinstance(antenna_params.radiation_efficiency, FreqDataArray) + assert isinstance(antenna_params.reflection_efficiency, FreqDataArray) + assert isinstance(antenna_params.gain, xr.DataArray) + assert isinstance(antenna_params.realized_gain, xr.DataArray) + + # Test partial gain computations in linear basis + partial_gain_linear = antenna_params.partial_gain(pol_basis="linear") + assert isinstance(partial_gain_linear, xr.Dataset) + assert "Gtheta" in partial_gain_linear + assert "Gphi" in partial_gain_linear + + # Test partial gain computations in circular basis + partial_gain_circular = antenna_params.partial_gain(pol_basis="circular") + assert isinstance(partial_gain_circular, xr.Dataset) + assert "Gright" in partial_gain_circular + assert "Gleft" in partial_gain_circular + + # Test partial realized gain computations in both bases + assert isinstance(antenna_params.partial_realized_gain("linear"), xr.Dataset) + assert isinstance(antenna_params.partial_realized_gain("circular"), xr.Dataset) + + # Test validation of pol_basis parameter + with pytest.raises(ValueError): + antenna_params.partial_gain("invalid") + with pytest.raises(ValueError): + antenna_params.partial_realized_gain("invalid") + + +def test_get_combined_antenna_parameters_data(monkeypatch, tmp_path): + """Test the computation of combined antenna parameters from multiple ports.""" + modeler = make_component_modeler(False, path_dir=str(tmp_path)) + sim = modeler.simulation + theta = np.linspace(0, np.pi, 101) + phi = np.linspace(0, 2 * np.pi, 201) + proj_distance = max(sim.size) * 100 + radiation_monitor = td.DirectivityMonitor( + size=sim.size, + center=sim.center, + freqs=modeler.freqs, + name="antenna_monitor", + far_field_approx=True, + proj_distance=proj_distance, + theta=theta, + phi=phi, + ) + modeler = modeler.updated_copy(radiation_monitors=[radiation_monitor]) + s_matrix = run_component_modeler(monkeypatch, modeler) + + # Define port amplitudes + port_amplitudes = {modeler.ports[0].name: 1.0, modeler.ports[1].name: 1j} + + # Get combined antenna parameters + antenna_params = modeler.get_antenna_metrics_data( + port_amplitudes, monitor_name="antenna_monitor" + ) + + # Check that essential properties exist and are correct type + assert isinstance(antenna_params.radiation_efficiency, FreqDataArray) + assert isinstance(antenna_params.reflection_efficiency, FreqDataArray) + assert isinstance(antenna_params.partial_gain(), xr.Dataset) + assert isinstance(antenna_params.gain, xr.DataArray) + assert isinstance(antenna_params.partial_realized_gain(), xr.Dataset) + assert isinstance(antenna_params.realized_gain, xr.DataArray) + + # Test with single port for comparison + single_port_params = modeler.get_antenna_metrics_data() + + # Values should be different when combining ports vs single port + assert not np.allclose(antenna_params.gain, single_port_params.gain) + assert not np.allclose( + antenna_params.radiation_efficiency, single_port_params.radiation_efficiency + ) diff --git a/tests/utils.py b/tests/utils.py index ee0ba3d47d..7caceb77d3 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1057,6 +1057,31 @@ def make_flux_data(monitor: td.FluxMonitor) -> td.FluxData: flux = make_data(coords=coords, data_array_type=td.FluxDataArray, is_complex=False) return td.FluxData(monitor=monitor, flux=flux) + def make_directivity_data(monitor: td.DirectivityMonitor) -> td.DirectivityData: + """make a random DirectivityData from a DirectivityMonitor.""" + + f = list(monitor.freqs) + r = np.atleast_1d(monitor.proj_distance) + theta = list(monitor.theta) + phi = list(monitor.phi) + fluxcoords = dict(f=f) + fluxdata = make_data(coords=fluxcoords, data_array_type=td.FluxDataArray, is_complex=False) + coords = dict(r=r, theta=theta, phi=phi, f=f) + scalar_field = make_data( + coords=coords, data_array_type=td.FieldProjectionAngleDataArray, is_complex=True + ) + return td.DirectivityData( + monitor=monitor, + flux=fluxdata, + Er=scalar_field, + Etheta=scalar_field, + Ephi=scalar_field, + Hr=scalar_field, + Htheta=scalar_field, + Hphi=scalar_field, + projection_surfaces=monitor.projection_surfaces, + ) + MONITOR_MAKER_MAP = { td.FieldMonitor: make_field_data, td.FieldTimeMonitor: make_field_time_data, @@ -1065,6 +1090,7 @@ def make_flux_data(monitor: td.FluxMonitor) -> td.FluxData: td.PermittivityMonitor: make_eps_data, td.DiffractionMonitor: make_diff_data, td.FluxMonitor: make_flux_data, + td.DirectivityMonitor: make_directivity_data, } data = [MONITOR_MAKER_MAP[type(mnt)](mnt) for mnt in simulation.monitors] diff --git a/tidy3d/__init__.py b/tidy3d/__init__.py index d5848cb99d..7f3a9753f2 100644 --- a/tidy3d/__init__.py +++ b/tidy3d/__init__.py @@ -12,6 +12,9 @@ SolidMedium, SolidSpec, ) +from tidy3d.components.microwave.data.monitor_data import ( + AntennaMetricsData, +) from tidy3d.components.spice.analysis.dc import ( ChargeToleranceSpec, IsothermalSteadyChargeDCAnalysis, @@ -661,4 +664,5 @@ def set_logging_level(level: str) -> None: "VoltageSourceType", "IsothermalSteadyChargeDCAnalysis", "ChargeToleranceSpec", + "AntennaMetricsData", ] diff --git a/tidy3d/components/data/monitor_data.py b/tidy3d/components/data/monitor_data.py index e84c0abbc7..d4f3f02139 100644 --- a/tidy3d/components/data/monitor_data.py +++ b/tidy3d/components/data/monitor_data.py @@ -4,6 +4,7 @@ import warnings from abc import ABC +from math import isclose from typing import Any, Callable, Dict, List, Tuple, Union import autograd.numpy as np @@ -57,6 +58,7 @@ EpsSpecType, Literal, Numpy, + PolarizationBasis, Size, Symmetry, TrackFreq, @@ -96,6 +98,8 @@ # how much to shift the adjoint field source for 0-D axes dimensions SHIFT_VALUE_ADJ_FLD_SRC = 1e-5 AXIAL_RATIO_CAP = 100 +# At this sampling rate, the computed area of a sphere is within ~1% of the true value. +MIN_ANGULAR_SAMPLES_SPHERE = 10 class MonitorData(AbstractMonitorData, ABC): @@ -119,6 +123,31 @@ def normalize(self, source_spectrum_fn: Callable[[float], complex]) -> Dataset: """Return copy of self after normalization is applied using source spectrum function.""" return self.copy() + def scale_fields_by_freq_array( + self, freq_array: FreqDataArray, method: str = None + ) -> MonitorData: + """Scale fields in :class:`.MonitorData` by an array of values stored in a :class:`.FreqDataArray`. + + Parameters + ---------- + freq_array : FreqDataArray + Array containing the scaling factors in the frequency domain. + method : str = None + Interpolation method to use when selecting frequency values. If None, uses default xarray + method. Passed to xarray's sel() method. + + Returns + ------- + :class:`.MonitorData` + A new instance of :class:`.MonitorData` with scaled field values. + """ + + # Reuse the normalize method, so we need the inverse of the scaling amplitude + def amplitude_fn(freq: list[float]) -> complex: + return 1.0 / freq_array.sel(f=freq, method=method).values + + return self.normalize(amplitude_fn) + def _updated(self, update: Dict) -> MonitorData: """Similar to ``updated_copy``, but does not actually copy components, for speed. @@ -2518,6 +2547,94 @@ def renormalize_fields(self, proj_distance: float) -> FieldProjectionAngleData: # compute updated fields and their coordinates return self.make_renormalized_data(phase, proj_distance) + @property + def tangential_dims(self) -> list[str]: + """Tangential dimensions to a spherical surface in the spherical coordinate system.""" + tangential_dims = ["theta", "phi"] + return tangential_dims + + @staticmethod + def _check_coords_sorted(coord: np.ndarray, name: str): + """Helper for checking whether an array is sorted and raises an exception if it is not.""" + is_sorted = np.all(np.diff(coord) >= 0) + if not is_sorted: + raise ValueError(f"{name} was not provided as a sorted array.") + + def _check_integration_suitability(self): + """Checks whether the sampling of ``theta`` and ``phi`` is suitable for + integrating over a spherical surface.""" + if ( + len(self.theta) < MIN_ANGULAR_SAMPLES_SPHERE + or len(self.phi) < 2 * MIN_ANGULAR_SAMPLES_SPHERE + ): + raise ValueError( + "There are not enough sampling points along `theta` or `phi` for accurate integration. " + f"Currently, {len(self.theta)} samples for `theta` and {len(self.phi)} samples for `phi`. " + f"Consider using, at the very least, {MIN_ANGULAR_SAMPLES_SPHERE} samples for `theta` and " + f"{2*MIN_ANGULAR_SAMPLES_SPHERE} samples for `phi`." + ) + self._check_coords_sorted(self.theta, "theta") + self._check_coords_sorted(self.phi, "phi") + if not isclose(self.theta[0], 0) or not isclose(self.theta[-1], np.pi): + raise ValueError( + "Chosen limits for `theta` are not appropriate for integration. " + "`theta` must range from 0 to π." + ) + if not isclose(self.phi[0], 0) or not isclose(self.phi[-1], 2 * np.pi): + raise ValueError( + "Chosen limits for `phi` are not appropriate for integration. " + "`phi` must range from 0 to 2π." + ) + + def flux_from_projected_fields(self) -> FluxDataArray: + """Flux calculated by integrating the projected fields on a spherical surface. + + Returns + ------- + :class:`.FluxDataArray` + Flux in the frequency domain. + """ + self._check_integration_suitability() + d_solid_angle = np.sin(self.Etheta.theta) + integrand = (self.power * d_solid_angle).sel(r=self.monitor.proj_distance) + flux = self.monitor.proj_distance**2 * integrand.integrate(self.tangential_dims) + return FluxDataArray(flux) + + @staticmethod + def get_phi_slice( + field_array: FieldProjectionAngleDataArray, phi: float, symmetric: bool = False + ) -> FieldProjectionAngleDataArray: + """Get a planar slice of the :class:`.FieldProjectionAngleDataArray` along a given phi angle. + Extends theta range from [0, π] to [0, 2π] to create a full slice. + + Parameters + ---------- + field_array : :class:`.FieldProjectionAngleDataArray` + Field array to slice. + phi : float + Angle phi in radians to slice at. + symmetric : bool = False + If True, uses same data for both halves. If False, takes opposite phi angle + for back half. + + Returns + ------- + :class:`.FieldProjectionAngleDataArray` + 2D slice with theta going from 0 to 2π. + """ + slice_phi = field_array.sel(phi=phi, method="nearest") + slice_phi = slice_phi.where(slice_phi.theta < np.pi) + if symmetric: + slice_opposite_phi = field_array.sel(phi=phi, method="nearest") + else: + slice_opposite_phi = field_array.sel(phi=phi + np.pi, method="nearest") + slice_opposite_phi = slice_opposite_phi.where(slice_opposite_phi.theta > 0) + slice_opposite_phi = slice_opposite_phi.assign_coords( + theta=(2 * np.pi - slice_opposite_phi.theta) + ) + data_array = xr.concat((slice_phi, slice_opposite_phi), dim="theta").sortby("theta") + return FieldProjectionAngleDataArray(data_array) + class FieldProjectionCartesianData(AbstractFieldProjectionData): """Data associated with a :class:`.FieldProjectionCartesianMonitor`: components of @@ -2627,7 +2744,7 @@ def poynting(self) -> DataArray: @cached_property def flux(self) -> FluxDataArray: - """Flux for projecteded field data corresponding to a Cartesian field projection monitor.""" + """Flux for projected field data corresponding to a Cartesian field projection monitor.""" flux = self.poynting.integrate(self.tangential_dims) return FluxDataArray(flux) @@ -3143,7 +3260,7 @@ def adjoint_source_amp(self, amp: DataArray, fwidth: float) -> PlaneWave: return adj_src -class DirectivityData(AbstractFieldProjectionData): +class DirectivityData(FieldProjectionAngleData): """ Data associated with a :class:`.DirectivityMonitor`. @@ -3161,51 +3278,73 @@ class DirectivityData(AbstractFieldProjectionData): >>> scalar_field = FieldProjectionAngleDataArray(values, coords=coords) >>> monitor = DirectivityMonitor(center=(1,2,3), size=(2,2,2), freqs=f, name='n2f_monitor', phi=phi, theta=theta) >>> data = DirectivityData(monitor=monitor, flux=flux_data, Er=scalar_field, Etheta=scalar_field, Ephi=scalar_field, - ... Hr=scalar_field, Htheta=scalar_field, Hphi=scalar_field) + ... Hr=scalar_field, Htheta=scalar_field, Hphi=scalar_field, projection_surfaces=monitor.projection_surfaces) """ monitor: DirectivityMonitor = pd.Field( ..., - title="Directivity monitor", + title="Monitor", description="Monitor describing the angle-based projection grid on which to measure directivity data.", ) - flux: FluxDataArray = pd.Field(..., title="Flux", description="Flux values.") - - Er: FieldProjectionAngleDataArray = pd.Field( - ..., - title="Er", - description="Spatial distribution of r-component of the electric field.", - ) - Etheta: FieldProjectionAngleDataArray = pd.Field( - ..., - title="Etheta", - description="Spatial distribution of the theta-component of the electric field.", - ) - Ephi: FieldProjectionAngleDataArray = pd.Field( - ..., - title="Ephi", - description="Spatial distribution of phi-component of the electric field.", - ) - Hr: FieldProjectionAngleDataArray = pd.Field( - ..., - title="Hr", - description="Spatial distribution of r-component of the magnetic field.", - ) - Htheta: FieldProjectionAngleDataArray = pd.Field( - ..., - title="Htheta", - description="Spatial distribution of theta-component of the magnetic field.", - ) - Hphi: FieldProjectionAngleDataArray = pd.Field( + flux: FluxDataArray = pd.Field( ..., - title="Hphi", - description="Spatial distribution of phi-component of the magnetic field.", + title="Flux", + description="Flux values that are either computed from fields recorded on the " + "projection surfaces or by integrating the projected fields over a spherical surface.", ) - def normalize( - self, source_spectrum_fn: Callable[[float], complex] - ) -> Union[AbstractFieldProjectionData, FluxData]: + @staticmethod + def from_spherical_field_dataset( + monitor: DirectivityMonitor, + field_dataset: xr.Dataset, + ) -> DirectivityData: + """Creates a :class:`.DirectivityData` instance from a spherical field dataset. + + Parameters + ---------- + monitor : :class:`.DirectivityMonitor` + Monitor defining measurement parameters. + field_dataset : ``xr.Dataset`` + Dataset containing spherical field components (Er, Etheta, etc.). + Must sample the entire spherical surface to compute flux correctly. + + Returns + ------- + :class:`.DirectivityData` + New :class:`.DirectivityData` instance with computed flux from spherical field integration. + """ + f = list(monitor.freqs) + flux = FluxDataArray(np.zeros(len(f)), coords=dict(f=f)) + dir_data = DirectivityData( + monitor=monitor, + flux=flux, + Er=field_dataset.Er, + Etheta=field_dataset.Etheta, + Ephi=field_dataset.Ephi, + Hr=field_dataset.Hr, + Htheta=field_dataset.Htheta, + Hphi=field_dataset.Hphi, + projection_surfaces=monitor.projection_surfaces, + ) + flux = dir_data.flux_from_projected_fields() + return dir_data.updated_copy(flux=flux) + + def __add__(self, other: DirectivityData) -> DirectivityData: + """Form the superposition of two :class:`.DirectivityData`. Flux is recomputed by + integrating the projected fields over a sphere. + + Note + ---- + Intended use is for combining fields from different simulations that were recorded + using the same ``monitor``. The returned :class:`.DirectivityData` takes the ``monitor`` + from ``self``. + """ + fields_dataset = self.fields_spherical + other.fields_spherical + combined_data = DirectivityData.from_spherical_field_dataset(self.monitor, fields_dataset) + return combined_data + + def normalize(self, source_spectrum_fn: Callable[[float], complex]) -> DirectivityData: """ Return a copy of self after normalization is applied using the source spectrum function, for both field components and flux data. @@ -3223,24 +3362,170 @@ def normalize( return self.copy(update=dict(fields_norm, flux=new_flux)) + @staticmethod + def _check_valid_pol_basis(pol_basis: PolarizationBasis): + if pol_basis != "linear" and pol_basis != "circular": + raise ValueError("``pol_basis`` must be either 'linear' or 'circular'") + + def partial_radiation_intensity(self, pol_basis: PolarizationBasis = "linear") -> xr.Dataset: + """Partial radiation intensity in the frequency domain as a function of angles theta and phi. + The partial radiation intensities are computed in the ``linear`` or ``circular`` polarization + bases. Radiation intensity is measured in units of Watts per unit solid angle. + + Parameters + ---------- + pol_basis : PolarizationBasis + The desired polarization basis used to express partial radiation intensity, either + ``linear`` or ``circular``. + + Returns + ------- + xarray.Dataset + Dataset containing the partial radiation intensities split into the two polarization states. + """ + self._check_valid_pol_basis(pol_basis) + if pol_basis == "linear": + E1 = self.Etheta + E2 = self.Ephi + H1 = self.Htheta + H2 = self.Hphi + keys = ("Utheta", "Uphi") + else: + E1 = self.fields_circular_polarization.Eright + E2 = self.fields_circular_polarization.Eleft + # needs extra -1 to counteract -1 in cross product below + H1 = -1.0 * self.fields_circular_polarization.Hleft + H2 = self.fields_circular_polarization.Hright + keys = ("Uright", "Uleft") + + U_1 = (self.monitor.proj_distance**2) * 0.5 * np.real(E1 * np.conj(H2)) + U_2 = (self.monitor.proj_distance**2) * 0.5 * np.real(-E2 * np.conj(H1)) + + data_arrays = (U_1, U_2) + return xr.Dataset(dict(zip(keys, data_arrays))) + + @property + def radiation_intensity(self) -> FieldProjectionAngleDataArray: + """Radiation intensity in the frequency domain as a function of angles theta and phi. + Radiation intensity is measured in units of Watts per unit solid angle. + """ + # Calls partial radiation intensity using default linear polarization basis + partial_U = self.partial_radiation_intensity() + return partial_U.Utheta + partial_U.Uphi + @property - def directivity(self) -> DataArray: + def radiated_power(self) -> FreqDataArray: + """Total radiated power in the frequency domain with units of Watts.""" + # If this data was created using FieldProjectionAngleData, the sign + # will already be correct. Also will be correct if monitor size is all nonzero. + # TODO fix this sign issue in the backend if possible + if ( + isinstance(self.monitor, FieldProjectionAngleMonitor) + or self.monitor.size.count(0.0) == 0 + ): + return FreqDataArray(self.flux.values, dict(f=self.f)) + # The monitor could be planar and directed downward + sign = 1.0 if self.monitor.normal_dir == "+" else -1.0 + return FreqDataArray(sign * self.flux.values, dict(f=self.f)) + + def partial_directivity(self, pol_basis: PolarizationBasis = "linear") -> xr.Dataset: + """Directivity in the frequency domain as a function of angles theta and phi. + The partial directivities are computed in the ``linear`` or ``circular`` polarization + bases. Directivity is a dimensionless quantity defined as the ratio + of the radiation intensity in a given direction to the average radiation intensity + over all directions. + + Parameters + ---------- + pol_basis : PolarizationBasis + The desired polarization basis used to express partial directivity, either + ``linear`` or ``circular``. + + Returns + ------- + ``xarray.Dataset`` + Dataset containing the partial directivities split into the two polarization states. + """ + self._check_valid_pol_basis(pol_basis) + if pol_basis == "linear": + rename_mapping = {"Utheta": "Dtheta", "Uphi": "Dphi"} + else: + rename_mapping = {"Uright": "Dright", "Uleft": "Dleft"} + # Average radiation intensity is total radiated power divided by 4 pi + avg_radiation_intensity = self.radiated_power / (4 * np.pi) + partial_U = self.partial_radiation_intensity(pol_basis=pol_basis) + partial_D = partial_U / avg_radiation_intensity + return partial_D.rename(rename_mapping) + + @property + def directivity(self) -> FieldProjectionAngleDataArray: """Directivity in the frequency domain as a function of angles theta and phi. Directivity is a dimensionless quantity defined as the ratio of the radiation - intensity in a given direction to the average radiation intensity over all directions.""" + intensity in a given direction to the average radiation intensity over all directions. + """ + # Calls partial directivity using default linear polarization basis + partial_D = self.partial_directivity() + return FieldProjectionAngleDataArray(partial_D.Dtheta + partial_D.Dphi) - power_theta = 0.5 * np.real(self.Etheta * np.conj(self.Hphi)) - power_phi = 0.5 * np.real(-self.Ephi * np.conj(self.Htheta)) - power = power_theta + power_phi + def calc_radiation_efficiency(self, power_in: FreqDataArray) -> FreqDataArray: + """Calculate radiation efficiency as the ratio of radiated power to input power. + + Parameters + ---------- + power_in : FreqDataArray + Power supplied to the radiating element in the frequency domain, in units of Watts. + + Returns + ------- + FreqDataArray + Radiation efficiency (dimensionless) in the frequency domain, computed as + radiated_power / power_in. + """ + return FreqDataArray((self.radiated_power / power_in).values, dict(f=self.f)) + + def calc_partial_gain( + self, power_in: FreqDataArray, pol_basis: PolarizationBasis = "linear" + ) -> xr.Dataset: + """The partial gain figures of merit for antennas. The partial gains are computed + in the ``linear`` or ``circular`` polarization bases. Gain is dimensionless. + + Parameters + ---------- + power_in : FreqDataArray + Power, in units of Watts, supplied to the radiating element in the frequency domain. + + pol_basis : PolarizationBasis + The desired polarization basis used to express partial gain, either + ``linear`` or ``circular``. + + Returns + ------- + ``xarray.Dataset`` + Dataset containing the partial gains split into the two polarization states. + """ + self._check_valid_pol_basis(pol_basis) + radiation_efficiency = self.calc_radiation_efficiency(power_in) + partial_D = self.partial_directivity(pol_basis=pol_basis) + partial_G = radiation_efficiency * partial_D + if pol_basis == "linear": + rename_mapping = {"Dtheta": "Gtheta", "Dphi": "Gphi"} + else: + rename_mapping = {"Dright": "Gright", "Dleft": "Gleft"} + return partial_G.rename(rename_mapping) - # Normalize the aligned flux by dividing by (4 * pi * r^2) to adjust the flux for - # spherical surface area normalization - flux_normed = self.flux / (4 * np.pi * self.monitor.proj_distance**2) + def calc_gain(self, power_in: FreqDataArray) -> FieldProjectionAngleDataArray: + """The gain figure of merit for antennas. Gain is dimensionless. - return power / flux_normed + Parameters + ---------- + power_in : FreqDataArray + Power, in units of Watts, supplied to the radiating element in the frequency domain. + """ + partial_G = self.calc_partial_gain(power_in) + return FieldProjectionAngleDataArray(partial_G.Gtheta + partial_G.Gphi) @property - def axial_ratio(self) -> DataArray: + def axial_ratio(self) -> FieldProjectionAngleDataArray: """Axial Ratio (AR) in the frequency domain as a function of angles theta and phi. AR is a dimensionless quantity defined as the ratio of the major axis to the minor axis of the polarization ellipse. @@ -3281,16 +3566,44 @@ def axial_ratio(self) -> DataArray: return 1 / axial_ratio_inverse @property - def left_polarization(self) -> DataArray: - "Electric far field for left-hand circular polarization" - "(counterclockwise component) with an angle-based projection grid." + def left_polarization(self) -> FieldProjectionAngleDataArray: + """Electric far field for left-hand circular polarization + (counterclockwise component) with an angle-based projection grid. + """ + return (self.Etheta + 1j * self.Ephi) / np.sqrt(2) + + @property + def right_polarization(self) -> FieldProjectionAngleDataArray: + """Electric far field for right-hand circular polarization + (clockwise component) with an angle-based projection grid. + """ return (self.Etheta - 1j * self.Ephi) / np.sqrt(2) @property - def right_polarization(self) -> DataArray: - "Electric far field for right-hand circular polarization" - "(clockwise component) with an angle-based projection grid." - return (self.Etheta + 1j * self.Ephi) / np.sqrt(2) + def fields_circular_polarization(self) -> xr.Dataset: + """Electric and magnetic fields in the circular polarization basis. + + Note + ---- + Uses IEEE handedness convention for polarization state, which means right-handed circularly + polarization is associated with a clockwise rotation of the electric field vector from the + point of the view of the source. However, we use the physics convention for time evolution + of time-harmonic fields, which modifies the computation when compared to engineering references. + + Returns + ------- + ``xarray.Dataset`` + xarray dataset containing (``Eleft``, ``Eright``, ``Hleft``, ``Hright``) + in Spherical coordinates. + """ + Eleft = (self.Etheta + 1j * self.Ephi) / np.sqrt(2.0) + Eright = (self.Etheta - 1j * self.Ephi) / np.sqrt(2.0) + Hleft = (self.Hphi - 1j * self.Htheta) / np.sqrt(2.0) + Hright = (self.Hphi + 1j * self.Htheta) / np.sqrt(2.0) + + keys = ("Eleft", "Eright", "Hleft", "Hright") + data_arrays = (Eleft, Eright, Hleft, Hright) + return xr.Dataset(dict(zip(keys, data_arrays))) MonitorDataTypes = ( diff --git a/tidy3d/components/microwave/data/__init__.py b/tidy3d/components/microwave/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tidy3d/components/microwave/data/monitor_data.py b/tidy3d/components/microwave/data/monitor_data.py new file mode 100644 index 0000000000..ef14e14171 --- /dev/null +++ b/tidy3d/components/microwave/data/monitor_data.py @@ -0,0 +1,173 @@ +"""Post-processing data and figures of merit for antennas, including radiation efficiency, +reflection efficiency, gain, and realized gain. +""" + +from __future__ import annotations + +import pydantic.v1 as pd +import xarray as xr + +from tidy3d.components.data.data_array import FieldProjectionAngleDataArray, FreqDataArray +from tidy3d.components.data.monitor_data import DirectivityData +from tidy3d.components.types import PolarizationBasis + + +class AntennaMetricsData(DirectivityData): + """Data representing the main parameters and figures of merit for antennas. + + Example + ------- + >>> import numpy as np + >>> from tidy3d.components.data.monitor_data import FluxDataArray, FieldProjectionAngleDataArray + >>> from tidy3d.components.monitor import DirectivityMonitor + >>> f = np.linspace(1e14, 2e14, 10) + >>> r = np.atleast_1d(1e6) + >>> theta = np.linspace(0, np.pi, 10) + >>> phi = np.linspace(0, 2*np.pi, 20) + >>> coords = dict(r=r, theta=theta, phi=phi, f=f) + >>> coords_flux = dict(f=f) + >>> field_values = (1+1j) * np.random.random((len(r), len(theta), len(phi), len(f))) + >>> flux_data = FluxDataArray(np.random.random(len(f)), coords=coords_flux) + >>> scalar_field = FieldProjectionAngleDataArray(field_values, coords=coords) + >>> monitor = DirectivityMonitor( + ... center=(1,2,3), + ... size=(2,2,2), + ... freqs=f, + ... name="rad_monitor", + ... phi=phi, + ... theta=theta + ... ) + >>> power_data = FreqDataArray(np.random.random(len(f)), coords=coords_flux) + >>> data = AntennaMetricsData( + ... monitor=monitor, + ... projection_surfaces=monitor.projection_surfaces, + ... flux=flux_data, + ... Er=scalar_field, + ... Etheta=scalar_field, + ... Ephi=scalar_field, + ... Hr=scalar_field, + ... Htheta=scalar_field, + ... Hphi=scalar_field, + ... power_incident=power_data, + ... power_reflected=power_data + ... ) + + Notes + ----- + The definitions of radiation efficiency, reflection efficiency, gain, and realized gain + are based on: + + Balanis, Constantine A., "Antenna Theory: Analysis and Design," + John Wiley & Sons, Chapter 2.9 (2016). + """ + + power_incident: FreqDataArray = pd.Field( + ..., + title="Power incident", + description="Array of values representing the incident power to an antenna.", + ) + + power_reflected: FreqDataArray = pd.Field( + ..., + title="Power reflected", + description="Array of values representing power reflected due to an impedance mismatch with the antenna.", + ) + + @staticmethod + def from_directivity_data( + dir_data: DirectivityData, power_inc: FreqDataArray, power_refl: FreqDataArray + ) -> AntennaMetricsData: + """Create :class:`.AntennaMetricsData` from directivity data and power measurements. + + Parameters + ---------- + dir_data : :class:`.DirectivityData` + Directivity data containing field components and flux measurements. + power_inc : :class:`.FreqDataArray` + Array of values representing the incident power to an antenna. + power_refl : :class:`.FreqDataArray` + Array of values representing power reflected due to impedance mismatch with the antenna. + + Returns + ------- + :class:`.AntennaMetricsData` + New instance combining directivity data with incident and reflected power measurements. + """ + antenna_params_dict = { + **dir_data.dict(), + "power_incident": power_inc, + "power_reflected": power_refl, + } + antenna_params_dict.pop("type") + return AntennaMetricsData(**antenna_params_dict) + + @property + def supplied_power(self) -> FreqDataArray: + """The power supplied to the antenna, which takes into account reflections.""" + return self.power_incident - self.power_reflected + + @property + def radiation_efficiency(self) -> FreqDataArray: + """The radiation efficiency of the antenna.""" + return self.calc_radiation_efficiency(self.supplied_power) + + @property + def reflection_efficiency(self) -> FreqDataArray: + """The reflection efficiency of the antenna, which is due to an impedance mismatch.""" + reflection_efficiency = self.supplied_power / self.power_incident + return reflection_efficiency + + def partial_gain(self, pol_basis: PolarizationBasis = "linear") -> xr.Dataset: + """The partial gain figures of merit for antennas. The partial gains are computed + in the ``linear`` or ``circular`` polarization bases. Gain is dimensionless. + + Parameters + ---------- + pol_basis : PolarizationBasis + The desired polarization basis used to express partial gain, either + ``linear`` or ``circular``. + + Returns + ------- + ``xarray.Dataset`` + Dataset containing the partial gains split into the two polarization states. + """ + self._check_valid_pol_basis(pol_basis) + partial_D = self.partial_directivity(pol_basis=pol_basis) + if pol_basis == "linear": + rename_mapping = {"Dtheta": "Gtheta", "Dphi": "Gphi"} + else: + rename_mapping = {"Dright": "Gright", "Dleft": "Gleft"} + return self.radiation_efficiency * partial_D.rename(rename_mapping) + + @property + def gain(self) -> FieldProjectionAngleDataArray: + """The gain figure of merit for antennas. Gain is dimensionless.""" + partial_G = self.partial_gain() + return partial_G.Gtheta + partial_G.Gphi + + def partial_realized_gain(self, pol_basis: PolarizationBasis = "linear") -> xr.Dataset: + """The partial realized gain figures of merit for antennas. The partial gains are computed + in the ``linear`` or ``circular`` polarization bases. Gain is dimensionless. + + Parameters + ---------- + pol_basis : PolarizationBasis + The desired polarization basis used to express partial gain, either + ``linear`` or ``circular``. + + Returns + ------- + ``xarray.Dataset`` + Dataset containing the partial realized gains split into the two polarization states. + """ + self._check_valid_pol_basis(pol_basis) + reflection_efficiency = self.reflection_efficiency + partial_G = self.partial_gain(pol_basis=pol_basis) + return reflection_efficiency * partial_G + + @property + def realized_gain(self) -> FieldProjectionAngleDataArray: + """The realized gain figure of merit for antennas. Realized gain is dimensionless.""" + partial_G = self.partial_realized_gain() + return partial_G.Gtheta + partial_G.Gphi diff --git a/tidy3d/components/types.py b/tidy3d/components/types.py index 884b904a18..636304e91c 100644 --- a/tidy3d/components/types.py +++ b/tidy3d/components/types.py @@ -226,6 +226,7 @@ def __modify_schema__(cls, field_schema): FieldType = Literal["Ex", "Ey", "Ez", "Hx", "Hy", "Hz"] FreqArray = Union[Tuple[float, ...], ArrayFloat1D] ObsGridArray = Union[Tuple[float, ...], ArrayFloat1D] +PolarizationBasis = Literal["linear", "circular"] """ plotting """ diff --git a/tidy3d/plugins/smatrix/__init__.py b/tidy3d/plugins/smatrix/__init__.py index e88ac42c54..8bde63e03f 100644 --- a/tidy3d/plugins/smatrix/__init__.py +++ b/tidy3d/plugins/smatrix/__init__.py @@ -1,11 +1,8 @@ """Imports from scattering matrix plugin.""" from .component_modelers.modal import AbstractComponentModeler, ComponentModeler, ModalPortDataArray -from .component_modelers.terminal import ( - PortDataArray, - TerminalComponentModeler, - TerminalPortDataArray, -) +from .component_modelers.terminal import TerminalComponentModeler +from .data.terminal import PortDataArray, TerminalPortDataArray from .ports.coaxial_lumped import CoaxialLumpedPort from .ports.modal import Port from .ports.rectangular_lumped import LumpedPort diff --git a/tidy3d/plugins/smatrix/component_modelers/base.py b/tidy3d/plugins/smatrix/component_modelers/base.py index c72f6fe75d..902a95ebed 100644 --- a/tidy3d/plugins/smatrix/component_modelers/base.py +++ b/tidy3d/plugins/smatrix/component_modelers/base.py @@ -11,8 +11,10 @@ from ....components.base import Tidy3dBaseModel, cached_property from ....components.data.data_array import DataArray +from ....components.data.sim_data import SimulationData from ....components.simulation import Simulation from ....components.types import FreqArray +from ....config import config from ....constants import HERTZ from ....exceptions import SetupError, Tidy3dKeyError from ....web.api.container import Batch, BatchData @@ -281,3 +283,11 @@ def _shift_value_signed(self, port: Union[Port, WavePort]) -> float: new_pos = grid_centers[shifted_index] return new_pos - port_position + + def sim_data_by_task_name(self, task_name: str) -> SimulationData: + """Get the simulation data by task name, avoids emitting warnings from the ``Simulation``.""" + log_level_cache = config.logging_level + config.logging_level = "ERROR" + sim_data = self.batch_data[task_name] + config.logging_level = log_level_cache + return sim_data diff --git a/tidy3d/plugins/smatrix/component_modelers/terminal.py b/tidy3d/plugins/smatrix/component_modelers/terminal.py index 3a6fd06ba0..4b4ee8890a 100644 --- a/tidy3d/plugins/smatrix/component_modelers/terminal.py +++ b/tidy3d/plugins/smatrix/component_modelers/terminal.py @@ -8,39 +8,32 @@ import pydantic.v1 as pd from ....components.base import cached_property -from ....components.data.data_array import DataArray, FreqDataArray +from ....components.data.data_array import ( + DataArray, + FreqDataArray, +) +from ....components.data.monitor_data import ( + MonitorData, +) from ....components.data.sim_data import SimulationData from ....components.geometry.utils_2d import snap_coordinate_to_grid +from ....components.microwave.data.monitor_data import AntennaMetricsData +from ....components.monitor import DirectivityMonitor from ....components.simulation import Simulation from ....components.source.time import GaussianPulse from ....components.types import Ax from ....components.viz import add_ax_if_none, equal_aspect from ....constants import C_0, OHM -from ....exceptions import Tidy3dError, ValidationError +from ....exceptions import Tidy3dError, Tidy3dKeyError, ValidationError from ....web.api.container import BatchData +from ..data.terminal import PortDataArray, TerminalPortDataArray from ..ports.base_lumped import AbstractLumpedPort -from ..ports.base_terminal import AbstractTerminalPort, TerminalPortDataArray from ..ports.coaxial_lumped import CoaxialLumpedPort from ..ports.rectangular_lumped import LumpedPort from ..ports.wave import WavePort from .base import FWIDTH_FRAC, AbstractComponentModeler, TerminalPortType -class PortDataArray(DataArray): - """Array of values over dimensions of frequency and port name. - - Example - ------- - >>> f = [2e9, 3e9, 4e9] - >>> ports = ["port1", "port2"] - >>> coords = dict(f=f, port=ports) - >>> fd = PortDataArray((1+1j) * np.random.random((3, 2)), coords=coords) - """ - - __slots__ = () - _dims = ("f", "port") - - class TerminalComponentModeler(AbstractComponentModeler): """Tool for modeling two-terminal multiport devices and computing port parameters with lumped and wave ports.""" @@ -52,6 +45,13 @@ class TerminalComponentModeler(AbstractComponentModeler): "For each port, one simulation will be run with a source that is associated with the port.", ) + radiation_monitors: tuple[DirectivityMonitor, ...] = pd.Field( + (), + title="Radiation Monitors", + description="Facilitates the calculation of figures-of-merit for antennas. " + "These monitor will be included in every simulation and record the radiated fields. ", + ) + @equal_aspect @add_ax_if_none def plot_sim( @@ -120,6 +120,9 @@ def sim_dict(self) -> Dict[str, Simulation]: new_mnts = list(self.simulation.monitors) + field_monitors + if self.radiation_monitors is not None: + new_mnts = new_mnts + list(self.radiation_monitors) + new_lumped_elements = list(self.simulation.lumped_elements) + [ port.to_load(snap_center=snap_centers[port.name]) for port in self._lumped_ports ] @@ -190,61 +193,19 @@ def _internal_construct_smatrix(self, batch_data: BatchData) -> TerminalPortData ) a_matrix = TerminalPortDataArray(values, coords=coords) b_matrix = a_matrix.copy(deep=True) - V_matrix = a_matrix.copy(deep=True) - I_matrix = a_matrix.copy(deep=True) - - def port_VI(port_out: AbstractTerminalPort, sim_data: SimulationData): - """Helper to compute the port voltages and currents.""" - voltage = port_out.compute_voltage(sim_data) - current = port_out.compute_current(sim_data) - return voltage, current # Tabulate the reference impedances at each port and frequency - port_impedances = self.port_reference_impedances(batch_data=batch_data) + port_impedances = self._port_reference_impedances(batch_data=batch_data) # loop through source ports for port_in in self.ports: sim_data = batch_data[self._task_name(port=port_in)] - for port_out in self.ports: - V_out, I_out = port_VI(port_out, sim_data) - V_matrix.loc[ - dict( - port_in=port_in.name, - port_out=port_out.name, - ) - ] = V_out - - I_matrix.loc[ - dict( - port_in=port_in.name, - port_out=port_out.name, - ) - ] = I_out - - # Reshape arrays so that broadcasting can be used to make F and Z act as diagonal matrices at each frequency - # Ensure data arrays have the correct data layout - V_numpy = V_matrix.transpose(*TerminalPortDataArray._dims).values - I_numpy = I_matrix.transpose(*TerminalPortDataArray._dims).values - Z_numpy = port_impedances.transpose(*PortDataArray._dims).values.reshape( - (len(self.freqs), len(port_names), 1) - ) - - # Check to make sure sign is consistent for all impedance values - self._check_port_impedance_sign(Z_numpy) - - # Check for negative real part of port impedance and flip the V and Z signs accordingly - negative_real_Z = np.real(Z_numpy) < 0 - V_numpy = np.where(negative_real_Z, -V_numpy, V_numpy) - Z_numpy = np.where(negative_real_Z, -Z_numpy, Z_numpy) - - F_numpy = TerminalComponentModeler._compute_F(Z_numpy) - - # Equation 4.67 - Pozar - Microwave Engineering 4ed - a_matrix.values = F_numpy * (V_numpy + Z_numpy * I_numpy) - b_matrix.values = F_numpy * (V_numpy - np.conj(Z_numpy) * I_numpy) + a, b = self.compute_power_wave_amplitudes_at_each_port(port_impedances, sim_data) + indexer = dict(f=a.f, port_in=port_in.name, port_out=a.port) + a_matrix.loc[indexer] = a + b_matrix.loc[indexer] = b s_matrix = self.ab_to_s(a_matrix, b_matrix) - return s_matrix @pd.validator("simulation") @@ -257,6 +218,19 @@ def _validate_3d_simulation(cls, val): ) return val + @pd.validator("radiation_monitors") + def _validate_radiation_monitors(cls, val, values): + freqs = set(values.get("freqs")) + for rad_mon in val: + mon_freqs = rad_mon.freqs + is_subset = freqs.issuperset(mon_freqs) + if not is_subset: + raise ValidationError( + f"The frequencies in the radiation monitor '{rad_mon.name}' " + f"must be equal to or a subset of the frequencies in the '{cls.__name__}'." + ) + return val + @staticmethod def _check_grid_size_at_ports(simulation: Simulation, ports: list[Union[AbstractLumpedPort]]): """Raises :class:`.SetupError` if the grid is too coarse at port locations""" @@ -264,15 +238,107 @@ def _check_grid_size_at_ports(simulation: Simulation, ports: list[Union[Abstract for port in ports: port._check_grid_size(yee_grid) + def compute_power_wave_amplitudes_at_each_port( + self, port_reference_impedances: PortDataArray, sim_data: SimulationData + ) -> tuple[PortDataArray, PortDataArray]: + """Compute the incident and reflected power wave amplitudes at each port. + The computed amplitudes have not been normalized. + + Parameters + ---------- + port_reference_impedances : :class:`.PortDataArray` + Reference impedance at each port. + sim_data : :class:`.SimulationData` + Results from the simulation. + + Returns + ------- + tuple[:class:`.PortDataArray`, :class:`.PortDataArray`] + Incident (a) and reflected (b) power wave amplitudes at each port. + """ + port_names = [port.name for port in self.ports] + values = np.zeros( + (len(self.freqs), len(port_names)), + dtype=complex, + ) + coords = dict( + f=np.array(self.freqs), + port=port_names, + ) + + V_matrix = PortDataArray(values, coords=coords) + I_matrix = V_matrix.copy(deep=True) + a = V_matrix.copy(deep=True) + b = V_matrix.copy(deep=True) + + for port_out in self.ports: + V_out, I_out = self.compute_port_VI(port_out, sim_data) + indexer = dict(port=port_out.name) + V_matrix.loc[indexer] = V_out + I_matrix.loc[indexer] = I_out + + V_numpy = V_matrix.values + I_numpy = I_matrix.values + Z_numpy = port_reference_impedances.values + + # Check to make sure sign is consistent for all impedance values + self._check_port_impedance_sign(Z_numpy) + + # # Check for negative real part of port impedance and flip the V and Z signs accordingly + negative_real_Z = np.real(Z_numpy) < 0 + V_numpy = np.where(negative_real_Z, -V_numpy, V_numpy) + Z_numpy = np.where(negative_real_Z, -Z_numpy, Z_numpy) + + F_numpy = TerminalComponentModeler._compute_F(Z_numpy) + + # Equation 4.67 - Pozar - Microwave Engineering 4ed + a.values = F_numpy * (V_numpy + Z_numpy * I_numpy) + b.values = F_numpy * (V_numpy - np.conj(Z_numpy) * I_numpy) + + return a, b + + @staticmethod + def compute_port_VI( + port_out: TerminalPortType, sim_data: SimulationData + ) -> tuple[FreqDataArray, FreqDataArray]: + """Compute the port voltages and currents. + + Parameters + ---------- + port_out : ``TerminalPortType`` + Port for computing voltage and current. + sim_data : :class:`.SimulationData` + Results from simulation containing field data. + + Returns + ------- + tuple[FreqDataArray, FreqDataArray] + Voltage and current values at the port as frequency arrays. + """ + voltage = port_out.compute_voltage(sim_data) + current = port_out.compute_current(sim_data) + return voltage, current + @staticmethod def compute_power_wave_amplitudes( port: Union[LumpedPort, CoaxialLumpedPort], sim_data: SimulationData ) -> tuple[FreqDataArray, FreqDataArray]: - """Helper to compute the incident and reflected power wave amplitudes at a port for a given - simulation result. The computed amplitudes have not been normalized. + """Compute the incident and reflected power wave amplitudes at a lumped port. + The computed amplitudes have not been normalized. + + Parameters + ---------- + port : Union[:class:`.LumpedPort`, :class:`.CoaxialLumpedPort`] + Port for computing voltage and current. + sim_data : :class:`.SimulationData` + Results from the simulation. + + Returns + ------- + tuple[FreqDataArray, FreqDataArray] + Incident (a) and reflected (b) power wave amplitude frequency arrays. """ - voltage = port.compute_voltage(sim_data) - current = port.compute_current(sim_data) + voltage, current = TerminalComponentModeler.compute_port_VI(port, sim_data) # Amplitudes for the incident and reflected power waves a = (voltage + port.impedance * current) / 2 / np.sqrt(np.real(port.impedance)) b = (voltage - port.impedance * current) / 2 / np.sqrt(np.real(port.impedance)) @@ -282,8 +348,19 @@ def compute_power_wave_amplitudes( def compute_power_delivered_by_port( port: Union[LumpedPort, CoaxialLumpedPort], sim_data: SimulationData ) -> FreqDataArray: - """Helper to compute the total power delivered to the network by a port for a given - simulation result. Units of power are Watts. + """Compute the power delivered to the network by a lumped port. + + Parameters + ---------- + port : Union[:class:`.LumpedPort`, :class:`.CoaxialLumpedPort`] + Port for computing voltage and current. + sim_data : :class:`.SimulationData` + Results from the simulation. + + Returns + ------- + FreqDataArray + Power in units of Watts as a frequency array. """ a, b = TerminalComponentModeler.compute_power_wave_amplitudes(sim_data=sim_data, port=port) # Power delivered is the incident power minus the reflected power @@ -338,8 +415,15 @@ def s_to_z( z_matrix.data = z_vals return z_matrix - def port_reference_impedances(self, batch_data: BatchData) -> PortDataArray: - """Tabulates the reference impedance of each port at each frequency.""" + @cached_property + def port_reference_impedances(self) -> PortDataArray: + """The reference impedance used at each port for definining power wave amplitudes.""" + return self._port_reference_impedances(self.batch_data) + + def _port_reference_impedances(self, batch_data: BatchData) -> PortDataArray: + """Tabulates the reference impedance of each port at each frequency using the + supplied :class:`.BatchData`. + """ port_names = [port.name for port in self.ports] values = np.zeros( @@ -385,10 +469,11 @@ def _set_port_data_array_attributes(data_array: PortDataArray) -> PortDataArray: data_array.name = "Z0" return data_array.assign_attrs(units=OHM, long_name="characteristic impedance") - def _check_port_impedance_sign(self, Z_numpy: np.ndarray): + @staticmethod + def _check_port_impedance_sign(Z_numpy: np.ndarray): """Sanity check for consistent sign of real part of Z for each port across all frequencies.""" for port_idx in range(Z_numpy.shape[1]): - port_Z = Z_numpy[:, port_idx, 0] + port_Z = Z_numpy[:, port_idx] signs = np.sign(np.real(port_Z)) if not np.all(signs == signs[0]): raise Tidy3dError( @@ -396,3 +481,135 @@ def _check_port_impedance_sign(self, Z_numpy: np.ndarray): "If you received this error, please create an issue in the Tidy3D " "github repository." ) + + def get_radiation_monitor_by_name(self, monitor_name: str) -> DirectivityMonitor: + """Find and return a :class:`.DirectivityMonitor` monitor by its name. + + Parameters + ---------- + monitor_name : str + Name of the monitor to find. + + Returns + ------- + :class:`.DirectivityMonitor` + The monitor matching the given name. + + Raises + ------ + ``Tidy3dKeyError`` + If no monitor with the given name exists. + """ + for monitor in self.radiation_monitors: + if monitor.name == monitor_name: + return monitor + raise Tidy3dKeyError(f"No radiation monitor named '{monitor_name}'.") + + def _monitor_data_at_port_amplitude( + self, + port: TerminalPortType, + sim_data: SimulationData, + monitor_data: MonitorData, + a_port: Union[FreqDataArray, complex], + ) -> MonitorData: + """Normalize the monitor data to a desired complex amplitude of a port, + represented by ``a_port``, where :math:`\\frac{1}{2}|a|^2` is the power + incident from the port into the system. + """ + a_raw, _ = self.compute_power_wave_amplitudes_at_each_port( + self.port_reference_impedances, sim_data + ) + a_raw_port = a_raw.sel(port=port.name) + if not isinstance(a_port, FreqDataArray): + freqs = list(monitor_data.monitor.freqs) + array_vals = a_port * np.ones(len(freqs)) + a_port = FreqDataArray(array_vals, coords=dict(f=freqs)) + scale_array = a_port / a_raw_port + return monitor_data.scale_fields_by_freq_array(scale_array, method="nearest") + + def get_antenna_metrics_data( + self, port_amplitudes: dict[str, complex] = None, monitor_name: str = None + ) -> AntennaMetricsData: + """Calculate antenna parameters using superposition of fields from multiple port excitations. + + The method computes the radiated far fields and port excitation power wave amplitudes + for a superposition of port excitations, which can be used to analyze antenna radiation + characteristics. + + Parameters + ---------- + port_amplitudes : dict[str, complex] = None + Dictionary mapping port names to their desired excitation amplitudes. For each port, + :math:`\\frac{1}{2}|a|^2` represents the incident power from that port into the system. + If None, uses only the first port without any scaling of the raw simulation data. + monitor_name : str = None + Name of the :class:`.DirectivityMonitor` to use for calculating far fields. + If None, uses the first monitor in `radiation_monitors`. + + Returns + ------- + :class:`.AntennaMetricsData` + Container with antenna parameters including directivity, gain, and radiation efficiency, + computed from the superposition of fields from all excited ports. + """ + # Use the first port as default if none specified + if port_amplitudes is None: + port_amplitudes = {self.ports[0].name: None} + port_names = [port.name for port in self.ports] + # Check port names, and create map from port to amplitude + port_dict = {} + for key in port_amplitudes.keys(): + port = self.get_port_by_name(port_name=key) + port_dict[port] = port_amplitudes[key] + # Get the radiation monitor, use first as default + # if none specified + if monitor_name is None: + rad_mon = self.radiation_monitors[0] + else: + rad_mon = self.get_radiation_monitor_by_name(monitor_name) + + # Create data arrays for holding the superposition of all port power wave amplitudes + f = list(rad_mon.freqs) + coords = dict(f=f, port=port_names) + a_sum = PortDataArray(np.zeros((len(f), len(port_names)), dtype=complex), coords=coords) + b_sum = a_sum.copy() + # Retrieve associated simulation data + combined_directivity_data = None + for port, amplitude in port_dict.items(): + sim_data_port = self.batch_data[self._task_name(port=port)] + radiation_data = sim_data_port[rad_mon.name] + + a, b = self.compute_power_wave_amplitudes_at_each_port( + self.port_reference_impedances, sim_data_port + ) + # Select a possible subset of frequencies + a = a.sel(f=f) + b = b.sel(f=f) + a_raw = a.sel(port=port.name) + + if amplitude is None: + # No scaling performed when amplitude is None + scaled_directivity_data = sim_data_port[rad_mon.name] + scale_factor = 1.0 + else: + scaled_directivity_data = self._monitor_data_at_port_amplitude( + port, sim_data_port, radiation_data, amplitude + ) + scale_factor = amplitude / a_raw + a = scale_factor * a + b = scale_factor * b + + # Combine the possibly scaled directivity data and the power wave amplitudes + if combined_directivity_data is None: + combined_directivity_data = scaled_directivity_data + else: + combined_directivity_data = combined_directivity_data + scaled_directivity_data + a_sum += a + b_sum += b + + # Compute and add power measures to results + power_incident = np.real(0.5 * a_sum * np.conj(a_sum)).sum(dim="port") + power_reflected = np.real(0.5 * b_sum * np.conj(b_sum)).sum(dim="port") + return AntennaMetricsData.from_directivity_data( + combined_directivity_data, power_incident, power_reflected + ) diff --git a/tidy3d/plugins/smatrix/data/__init__.py b/tidy3d/plugins/smatrix/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tidy3d/plugins/smatrix/data/terminal.py b/tidy3d/plugins/smatrix/data/terminal.py new file mode 100644 index 0000000000..301e5c5401 --- /dev/null +++ b/tidy3d/plugins/smatrix/data/terminal.py @@ -0,0 +1,43 @@ +"""Storing data associated with results from the TerminalComponentModeler""" + +from __future__ import annotations + +from ....components.data.data_array import ( + DataArray, +) + + +class PortDataArray(DataArray): + """Array of values over dimensions of frequency and port name. + + Example + ------- + >>> import numpy as np + >>> f = [2e9, 3e9, 4e9] + >>> ports = ["port1", "port2"] + >>> coords = dict(f=f, port=ports) + >>> data = (1+1j) * np.random.random((3, 2)) + >>> pd = PortDataArray(data, coords=coords) + """ + + __slots__ = () + _dims = ("f", "port") + + +class TerminalPortDataArray(DataArray): + """Port parameter matrix elements for terminal-based ports. + + Example + ------- + >>> import numpy as np + >>> ports_in = ["port1", "port2"] + >>> ports_out = ["port1", "port2"] + >>> f = [2e14] + >>> coords = dict(f=f, port_out=ports_out, port_in=ports_in) + >>> data = (1+1j) * np.random.random((1, 2, 2)) + >>> td = TerminalPortDataArray(data, coords=coords) + """ + + __slots__ = () + _dims = ("f", "port_out", "port_in") + _data_attrs = {"long_name": "terminal-based port matrix element"} diff --git a/tidy3d/plugins/smatrix/ports/base_terminal.py b/tidy3d/plugins/smatrix/ports/base_terminal.py index b350b3fed2..5d00fde0cb 100644 --- a/tidy3d/plugins/smatrix/ports/base_terminal.py +++ b/tidy3d/plugins/smatrix/ports/base_terminal.py @@ -5,7 +5,7 @@ import pydantic.v1 as pd from ....components.base import Tidy3dBaseModel, cached_property -from ....components.data.data_array import DataArray, FreqDataArray +from ....components.data.data_array import FreqDataArray from ....components.data.sim_data import SimulationData from ....components.grid.grid import Grid from ....components.monitor import FieldMonitor @@ -14,28 +14,6 @@ from ....components.types import FreqArray -class TerminalPortDataArray(DataArray): - """Port parameter matrix elements for terminal-based ports. - - Example - ------- - >>> import numpy as np - >>> ports_in = ['port1', 'port2'] - >>> ports_out = ['port1', 'port2'] - >>> f = [2e14] - >>> coords = dict( - ... f=f, - ... port_out=ports_out, - ... port_in=ports_in, - ... ) - >>> fd = TerminalPortDataArray((1 + 1j) * np.random.random((1, 2, 2)), coords=coords) - """ - - __slots__ = () - _dims = ("f", "port_out", "port_in") - _data_attrs = {"long_name": "terminal-based port matrix element"} - - class AbstractTerminalPort(Tidy3dBaseModel, ABC): """Class representing a single terminal-based port. All terminal ports must provide methods for computing voltage and current. These quantities represent the voltage between the