From 2e21c89c734ce4faed90ca2214c98c100f23ed97 Mon Sep 17 00:00:00 2001 From: Devin <38879940+dcrawforAtAnsys@users.noreply.github.com> Date: Mon, 13 May 2024 16:54:40 +0200 Subject: [PATCH] FEAT: Improve antenna array processing and plotting (#4626) Co-authored-by: Samuel Lopez <85613111+Samuelopez-ansys@users.noreply.github.com> Co-authored-by: Samuelopez-ansys Co-authored-by: Kathy Pippert <84872299+PipKat@users.noreply.github.com> --- _unittest/test_12_PostProcessing.py | 5 ++ pyaedt/application/analysis_hf.py | 61 +++++++++++++++ pyaedt/generic/plot.py | 92 ++++++++++++++++------ pyaedt/hfss.py | 26 ++++++- pyaedt/modules/solutions.py | 115 +++++++++++++++++++++------- 5 files changed, 241 insertions(+), 58 deletions(-) diff --git a/_unittest/test_12_PostProcessing.py b/_unittest/test_12_PostProcessing.py index 5f6e828181b..2375a7e7acb 100644 --- a/_unittest/test_12_PostProcessing.py +++ b/_unittest/test_12_PostProcessing.py @@ -637,6 +637,7 @@ def test_71_antenna_plot(self, field_test): title="Contour at {}Hz".format(ffdata.frequency), image_path=os.path.join(self.local_scratch.path, "contour.jpg"), convert_to_db=True, + show=False, ) assert os.path.exists(os.path.join(self.local_scratch.path, "contour.jpg")) @@ -686,6 +687,7 @@ def test_72_antenna_plot(self, array_test): title="Contour at {}Hz".format(ffdata.frequency), image_path=os.path.join(self.local_scratch.path, "contour.jpg"), convert_to_db=True, + show=False, ) assert os.path.exists(os.path.join(self.local_scratch.path, "contour.jpg")) @@ -695,6 +697,7 @@ def test_72_antenna_plot(self, array_test): secondary_sweep_value=[-180, -75, 75], title="Azimuth at {}Hz".format(ffdata.frequency), image_path=os.path.join(self.local_scratch.path, "2d1.jpg"), + show=False, ) assert os.path.exists(os.path.join(self.local_scratch.path, "2d1.jpg")) ffdata.plot_2d_cut( @@ -703,6 +706,7 @@ def test_72_antenna_plot(self, array_test): secondary_sweep_value=30, title="Azimuth at {}Hz".format(ffdata.frequency), image_path=os.path.join(self.local_scratch.path, "2d2.jpg"), + show=False, ) assert os.path.exists(os.path.join(self.local_scratch.path, "2d2.jpg")) @@ -725,6 +729,7 @@ def test_72_antenna_plot(self, array_test): title="Contour at {}Hz".format(ffdata1.frequency), image_path=os.path.join(self.local_scratch.path, "contour1.jpg"), convert_to_db=True, + show=False, ) assert os.path.exists(os.path.join(self.local_scratch.path, "contour1.jpg")) diff --git a/pyaedt/application/analysis_hf.py b/pyaedt/application/analysis_hf.py index b6a1e60d455..fd7208b791c 100644 --- a/pyaedt/application/analysis_hf.py +++ b/pyaedt/application/analysis_hf.py @@ -360,3 +360,64 @@ def export_touchstone( impedance=impedance, comments=gamma_impedance_comments, ) + + +def phase_expression(m, n, theta_name="theta_scan", phi_name="phi_scan"): + """Return an expression for the source phase angle in a rectangular antenna array. + + Parameters + ---------- + m : int, required + Index of the rectangular antenna array element in the x direction. + n : int, required + Index of the rectangular antenna array element in the y direction. + theta_name : str, optional + Postprocessing variable name in HFSS to use for the + theta component of the phase angle expression. The default is ``"theta_scan"``. + phi_name : str, optional + Postprocessing variable name in HFSS to use to generate + the phi component of the phase angle expression. The default is ``"phi_scan"`` + + Returns + ------- + str + Phase angle expression for the (m,n) source of + the (m,n) antenna array element. + + """ + # px is the term for the phase variation in the x direction. + # py is the term for the phase variation in the y direction. + + if n > 0: + add_char = " + " + else: + add_char = " - " + if m == 0: + px = "" + elif m == -1: + px = "-pi*sin(theta_scan)*cos(phi_scan)" + elif m == 1: + px = "pi*sin(theta_scan)*cos(phi_scan)" + else: + px = str(m) + "*pi*sin(theta_scan)*cos(phi_scan)" + if n == 0: + py = "" + elif n == -1 or n == 1: + py = "pi*sin(theta_scan)*sin(phi_scan)" + + else: + py = str(abs(n)) + "*pi*sin(theta_scan)*sin(phi_scan)" + if m == 0: + if n == 0: + return "0" + elif n < 0: + return "-" + py + else: + return py + elif n == 0: + if m == 0: + return "0" + else: + return px + else: + return px + add_char + py diff --git a/pyaedt/generic/plot.py b/pyaedt/generic/plot.py index 76c8a18b5ad..839ac8aea27 100644 --- a/pyaedt/generic/plot.py +++ b/pyaedt/generic/plot.py @@ -38,13 +38,37 @@ from matplotlib.path import Path import matplotlib.pyplot as plt + rc_params = { + "axes.titlesize": 26, # Use these default settings for Matplotlb axes. + "axes.labelsize": 20, # Apply the settings only in this module. + "xtick.labelsize": 18, + "ytick.labelsize": 18, + } + except ImportError: warnings.warn( "The Matplotlib module is required to run some functionalities of PostProcess.\n" "Install with \n\npip install matplotlib\n\nRequires CPython." ) except Exception: - pass + warnings.warn("Unknown error occurred while attempting to import Matplotlib.") + + +# Override default settings for matplotlib +def update_plot_settings(func, *args, **kwargs): + if callable(func): + + def wrapper(*args, **kwargs): + default_rc_params = plt.rcParams.copy() + plt.rcParams.update(rc_params) # Apply new settings. + out = func(*args, **kwargs) + plt.rcParams.update(default_rc_params) + return out + + else: + wrapper = None + raise TypeError("First argument must be callable.") + return wrapper @pyaedt_function_handler() @@ -100,6 +124,7 @@ def is_float(istring): try: return float(istring.strip()) except Exception: + warnings.warn("Unable to convert '" + istring.strip() + "' to a float.") return 0 @@ -293,10 +318,11 @@ def _parse_streamline(filepath): @pyaedt_function_handler() +@update_plot_settings def plot_polar_chart( - plot_data, size=(2000, 1000), show_legend=True, xlabel="", ylabel="", title="", snapshot_path=None + plot_data, size=(2000, 1000), show_legend=True, xlabel="", ylabel="", title="", snapshot_path=None, show=True ): - """Create a matplotlib polar plot based on a list of data. + """Create a Matplotlib polar plot based on a list of data. Parameters ---------- @@ -312,9 +338,17 @@ def plot_polar_chart( ylabel : str Plot Y label. title : str - Plot Title label. + Plot title label. snapshot_path : str - Full path to image file if a snapshot is needed. + Full path to the image file if a snapshot is needed. + show : bool, optional + Whether to render the figure. The default is ``True``. If ``False``, the + figure is not drawn. + + Returns + ------- + :class:`matplotlib.pyplot.Figure` + Matplotlib figure object. """ dpi = 100.0 @@ -344,14 +378,15 @@ def plot_polar_chart( fig.set_size_inches(size[0] / dpi, size[1] / dpi) if snapshot_path: fig.savefig(snapshot_path) - else: + if show: fig.show() return fig @pyaedt_function_handler() +@update_plot_settings def plot_3d_chart(plot_data, size=(2000, 1000), xlabel="", ylabel="", title="", snapshot_path=None): - """Create a matplotlib 3D plot based on a list of data. + """Create a Matplotlib 3D plot based on a list of data. Parameters ---------- @@ -371,8 +406,8 @@ def plot_3d_chart(plot_data, size=(2000, 1000), xlabel="", ylabel="", title="", Returns ------- - :class:`matplotlib.plt` - Matplotlib fig object. + :class:`matplotlib.pyplot.Figure` + Matplotlib figure object. """ dpi = 100.0 @@ -403,9 +438,9 @@ def plot_3d_chart(plot_data, size=(2000, 1000), xlabel="", ylabel="", title="", @pyaedt_function_handler() +@update_plot_settings def plot_2d_chart(plot_data, size=(2000, 1000), show_legend=True, xlabel="", ylabel="", title="", snapshot_path=None): - """Create a matplotlib plot based on a list of data. - + """Create a Matplotlib plot based on a list of data. Parameters ---------- plot_data : list of list @@ -427,8 +462,8 @@ def plot_2d_chart(plot_data, size=(2000, 1000), show_legend=True, xlabel="", yla Returns ------- - :class:`matplotlib.plt` - Matplotlib fig object. + :class:`matplotlib.pyplot.Figure` + Matplotlib figure object. """ dpi = 100.0 figsize = (size[0] / dpi, size[1] / dpi) @@ -460,6 +495,7 @@ def plot_2d_chart(plot_data, size=(2000, 1000), show_legend=True, xlabel="", yla @pyaedt_function_handler() +@update_plot_settings def plot_matplotlib( plot_data, size=(2000, 1000), @@ -511,8 +547,8 @@ def plot_matplotlib( Returns ------- - :class:`matplotlib.plt` - Matplotlib fig object. + :class:`matplotlib.pyplot.Figure` + Matplotlib Figure object. """ dpi = 100.0 figsize = (size[0] / dpi, size[1] / dpi) @@ -569,14 +605,17 @@ def plot_matplotlib( if snapshot_path: plt.savefig(snapshot_path) - elif show: + if show: plt.show() - return plt + return fig @pyaedt_function_handler() -def plot_contour(qty_to_plot, x, y, size=(2000, 1600), xlabel="", ylabel="", title="", levels=64, snapshot_path=None): - """Create a matplotlib contour plot. +@update_plot_settings +def plot_contour( + qty_to_plot, x, y, size=(2000, 1600), xlabel="", ylabel="", title="", levels=64, snapshot_path=None, show=True +): + """Create a Matplotlib contour plot. Parameters ---------- @@ -595,14 +634,17 @@ def plot_contour(qty_to_plot, x, y, size=(2000, 1600), xlabel="", ylabel="", tit title : str, optional Plot Title Label. Default is `""`. levels : int, optional - Color map levels. Default is `64`. + Color map levels. The default is ``64``. snapshot_path : str, optional - Full path to image to save. Default is None. + Full path to save the image save. The default is ``None``. + show : bool, optional + Whether to render the figure. The default is ``True``. If + ``False``, the image is not drawn. Returns ------- - :class:`matplotlib.plt` - Matplotlib fig object. + :class:`matplotlib.pyplot.Figure` + Matplotlib figure object. """ dpi = 100.0 figsize = (size[0] / dpi, size[1] / dpi) @@ -625,9 +667,9 @@ def plot_contour(qty_to_plot, x, y, size=(2000, 1600), xlabel="", ylabel="", tit plt.colorbar() if snapshot_path: plt.savefig(snapshot_path) - else: + if show: plt.show() - return plt + return fig class ObjClass(object): diff --git a/pyaedt/hfss.py b/pyaedt/hfss.py index 35e3bc7f001..bea573d1011 100644 --- a/pyaedt/hfss.py +++ b/pyaedt/hfss.py @@ -5237,13 +5237,14 @@ def set_differential_pair( @pyaedt_function_handler(array_name="name", json_file="input_data") def add_3d_component_array_from_json(self, input_data, name=None): - """Add or edit a 3D component array from a JSON file or TOML file. + """Add or edit a 3D component array from a JSON file, TOML file, or dictionary. The 3D component is placed in the layout if it is not present. Parameters ---------- input_data : str, dict - Full path to either the JSON file or dictionary containing the array information. + Full path to either the JSON file, TOML file, or the dictionary + containing the array information. name : str, optional Name of the boundary to add or edit. @@ -5417,8 +5418,11 @@ def get_antenna_ffd_solution_data( sphere=None, variations=None, overwrite=True, + link_to_hfss=True, ): - """Export antennas parameters to Far Field Data (FFD) files and return the ``FfdSolutionDataExporter`` object. + """Export the antenna parameters to Far Field Data (FFD) files and return an + instance of the + ``FfdSolutionDataExporter`` object. For phased array cases, only one phased array is calculated. @@ -5435,12 +5439,20 @@ def get_antenna_ffd_solution_data( Variation dictionary. overwrite : bool, optional Whether to overwrite FFD files. The default is ``True``. + link_to_hfss : bool, optional + Whether to return an instance of the + :class:`pyaedt.modules.solutions.FfdSolutionDataExporter` class, + which requires a connection to an instance of the :class:`Hfss` class. + The default is `` True``. If ``False``, returns an instance of + :class:`pyaedt.modules.solutions.FfdSolutionData` class, which is + independent from the running HFSS instance. Returns ------- :class:`pyaedt.modules.solutions.FfdSolutionDataExporter` SolutionData object. """ + from pyaedt.modules.solutions import FfdSolutionData from pyaedt.modules.solutions import FfdSolutionDataExporter if not variations: @@ -5467,7 +5479,7 @@ def get_antenna_ffd_solution_data( ) self.logger.info("Far field sphere %s is created.", setup) - return FfdSolutionDataExporter( + ffd = FfdSolutionDataExporter( self, sphere_name=sphere, setup_name=setup, @@ -5475,6 +5487,12 @@ def get_antenna_ffd_solution_data( variations=variations, overwrite=overwrite, ) + if link_to_hfss: + return ffd + else: + eep_file = ffd.eep_files + frequencies = ffd.frequencies + return FfdSolutionData(frequencies=frequencies, eep_files=eep_file) @pyaedt_function_handler() def set_material_threshold(self, threshold=100000): diff --git a/pyaedt/modules/solutions.py b/pyaedt/modules/solutions.py index 1b443ba2ae5..a4176ef30f0 100644 --- a/pyaedt/modules/solutions.py +++ b/pyaedt/modules/solutions.py @@ -24,7 +24,6 @@ from pyaedt.generic.plot import is_notebook from pyaedt.generic.plot import plot_2d_chart from pyaedt.generic.plot import plot_3d_chart -from pyaedt.generic.plot import plot_contour from pyaedt.generic.plot import plot_polar_chart from pyaedt.generic.settings import settings from pyaedt.modeler.cad.elements3d import FacePrimitive @@ -45,6 +44,10 @@ import pyvista as pv except ImportError: pv = None + try: + import matplotlib.pyplot as plt + except ImportError: + plt = None class SolutionData(object): @@ -1088,18 +1091,21 @@ def ifft_to_file( class FfdSolutionData(object): - """Contains information from the far field solution data. + """Provides antenna array far-field data. - Load far field data from the element pattern files. + Read embedded element patterns generated in HFSS and return the Python interface + to plot and analyze the array far-field data. Parameters ---------- eep_files : list or str - List of element pattern files for each frequency. - If the input is string, it is assumed to be a single frequency. + List of embedded element pattern files for each frequency. + If data is only provided for a single frequency, then a string can be passed + instead of a one-element list. frequencies : list, str, int, or float List of frequencies. - If the input is not a list, it is assumed to be a single frequency. + If data is only available for a single frequency, then a float or integer may be passed + instead of a one-element list. Examples -------- @@ -1526,11 +1532,14 @@ def plot_farfield_contour( quantity="RealizedGain", phi=0, theta=0, - title="RectangularPlot", + size=None, + title=None, quantity_format="dB10", image_path=None, levels=64, show=True, + polar=True, + max_theta=180, **kwargs ): # fmt: on @@ -1546,6 +1555,9 @@ def plot_farfield_contour( Phi scan angle in degrees. The default is ``0``. theta : float, int, optional Theta scan angle in degrees. The default is ``0``. + size : tuple, optional + Image size in pixel (width, height). The default is ``None``, in which case resolution + is determined automatically. title : str, optional Plot title. The default is ``"RectangularPlot"``. quantity_format : str, optional @@ -1559,18 +1571,22 @@ def plot_farfield_contour( show : bool, optional Whether to show the plot. The default is ``True``. If ``False``, the Matplotlib instance of the plot is shown. + polar : bool, optional + Generate the plot in polar coordinates. The default is ``True``. If ``False``, the plot + generated is rectangular. + max_theta : float or int, optional + Maxmum theta angle for plotting. The default is ``180``, which plots the far-field data for + all angles. Setting ``max_theta`` to 90 limits the displayed data to the upper + hemisphere, that is (0 < theta < 90). Returns ------- - :class:`matplotlib.plt` - Whether to show the plotted curve. - If ``show=True``, a Matplotlib figure instance of the plot is returned. - If ``show=False``, the plotted curve is returned. + :class:`matplotlib.pyplot.Figure` Examples -------- >>> import pyaedt - >>> app = pyaedt.Hfss(specified_version="2023.2", designname="Antenna") + >>> app = pyaedt.Hfss(specified_version="2024.1", designname="Antenna") >>> setup_name = "Setup1 : LastAdaptive" >>> frequencies = [77e9] >>> sphere = "3D" @@ -1578,6 +1594,8 @@ def plot_farfield_contour( >>> data.plot_farfield_contour() """ + if not title: + title = quantity for k in kwargs: if k == "convert_to_db": # pragma: no cover self.logger.warning("`convert_to_db` is deprecated since v0.7.8. Use `quantity_format` instead.") @@ -1594,27 +1612,54 @@ def plot_farfield_contour( if quantity not in data: # pragma: no cover self.logger.error("Far field quantity is not available.") return False + select = np.abs(data["Theta"]) <= max_theta # Limit theta range for plotting. - data_to_plot = data[quantity] + data_to_plot = data[quantity][select, :] data_to_plot = conversion_function(data_to_plot, quantity_format) if not isinstance(data_to_plot, np.ndarray): # pragma: no cover self.logger.error("Wrong format quantity") return False - data_to_plot = np.reshape(data_to_plot, (data["nTheta"], data["nPhi"])) - th, ph = np.meshgrid(data["Theta"], data["Phi"]) - if show: - return plot_contour( - x=th, - y=ph, - qty_to_plot=data_to_plot, - xlabel="Theta (degree)", - ylabel="Phi (degree)", - title=title, - levels=levels, - snapshot_path=image_path, - ) + ph, th = np.meshgrid(data["Phi"], data["Theta"][select]) + ph = ph * np.pi/180 if polar else ph + # Convert to radians for polar plot. + + # TODO: Is it necessary to set the plot size? + + default_figsize = plt.rcParams["figure.figsize"] + if size: # Retain this code to remain consistent with other plotting methods. + dpi = 100.0 + figsize = (size[0] / dpi, size[1] / dpi) + plt.rcParams["figure.figsize"] = figsize else: - return data_to_plot + figsize = default_figsize + + projection = 'polar' if polar else 'rectilinear' + fig, ax = plt.subplots(subplot_kw={'projection': projection}, figsize=figsize) + + fig.suptitle(title) + ax.set_xlabel("$\phi$ (Degrees)") + if polar: + ax.set_rticks(np.linspace(0, max_theta, 3)) + else: + ax.set_ylabel("$\\theta (Degrees") + + plt.contourf( + ph, + th, + data_to_plot, + levels=levels, + cmap="jet", + ) + cbar = plt.colorbar() + cbar.set_label(quantity_format, rotation=270, labelpad=20) + + if image_path: + plt.savefig(image_path) + if show: # pragma: no cover + plt.show() + + plt.rcParams["figure.figsize"] = default_figsize + return fig # fmt: off @pyaedt_function_handler(farfield_quantity="quantity", @@ -1865,7 +1910,7 @@ def polar_plot_3d( x = r * np.sin(theta_grid) * np.cos(phi_grid) y = r * np.sin(theta_grid) * np.sin(phi_grid) z = r * np.cos(theta_grid) - if show: + if show: # pragma: no cover plot_3d_chart([x, y, z], xlabel="Theta", ylabel="Phi", title=title, snapshot_path=image_path) else: return x, y, z @@ -2420,7 +2465,19 @@ def _rotation_to_euler_angles(R): class FfdSolutionDataExporter(FfdSolutionData): - """Export far field solution data. + """Class to enable export of embedded element pattern data from HFSS. + + An instance of this class is returned from the + :meth:`pyaedt.Hfss.get_antenna_ffd_solution_data` method. This method allows creation of + the embedded + element pattern (EEP) files for an antenna array that have been solved in HFSS. The + ``frequencies`` and ``eep_files`` properties can then be passed as arguments to + instantiate an instance of the :class:`pyaedt.modules.solutions.FfdSolutionData` class for + subsequent analysis and postprocessing of the array data. + + Note that this class is derived from the :class:`FfdSolutionData` class and can be used directly for + far-field postprocessing and array analysis, but it remains a property of the + :class:`pyaedt.Hfss` application. Parameters ----------