diff --git a/doc/changelog.d/6221.added.md b/doc/changelog.d/6221.added.md new file mode 100644 index 00000000000..772cf9993ff --- /dev/null +++ b/doc/changelog.d/6221.added.md @@ -0,0 +1 @@ +Frtm new methods and doa new features \ No newline at end of file diff --git a/doc/styles/config/vocabularies/ANSYS/accept.txt b/doc/styles/config/vocabularies/ANSYS/accept.txt index 4817b2a5578..9ab8a4267f5 100644 --- a/doc/styles/config/vocabularies/ANSYS/accept.txt +++ b/doc/styles/config/vocabularies/ANSYS/accept.txt @@ -8,6 +8,7 @@ airgap (?i)Ansys API autosave +beamforming brd busbar busbars diff --git a/src/ansys/aedt/core/visualization/advanced/doa.py b/src/ansys/aedt/core/visualization/advanced/doa.py new file mode 100644 index 00000000000..6cd31898267 --- /dev/null +++ b/src/ansys/aedt/core/visualization/advanced/doa.py @@ -0,0 +1,387 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import sys + +import numpy as np + +from ansys.aedt.core.generic.constants import SpeedOfLight +from ansys.aedt.core.generic.general_methods import conversion_function +from ansys.aedt.core.generic.general_methods import pyaedt_function_handler +from ansys.aedt.core.visualization.plot.matplotlib import ReportPlotter + +current_python_version = sys.version_info[:2] +if current_python_version < (3, 10): # pragma: no cover + raise Exception("Python 3.10 or higher is required for direction of arrival (DoA) post-processing.") + + +class DirectionOfArrival: + """ + Class for direction of arrival (DoA) estimation using 2D planar antenna arrays + with coordinates in meters and user-defined frequency. + """ + + def __init__(self, x_position: np.array, y_position: np.array, frequency: float): + """ + Initialize with antenna element positions in meters and signal frequency in Hertz. + + Parameters + ---------- + x_position : np.ndarray + X coordinates of the antenna elements in meters. + y_position : np.ndarray + Y coordinates of the antenna elements in meters. + frequency : float + Signal frequency in Hertz. + """ + self.x = np.asarray(x_position) + self.y = np.asarray(y_position) + self.elements = len(self.x) + self.frequency = frequency + self.wavelength = SpeedOfLight / self.frequency + self.k = 2 * np.pi / self.wavelength + + if self.elements != len(self.y): + raise ValueError("X and Y coordinate arrays must have the same length.") + + @pyaedt_function_handler() + def get_scanning_vectors(self, azimuth_angles: np.ndarray) -> np.ndarray: + """ + Generate scanning vectors for the given azimuth angles in degrees. + + Parameters + ---------- + azimuth_angles : np.ndarray + Incident azimuth angles in degrees. + + Returns + ------- + scanning_vectors : np.ndarray + Scanning vectors. + """ + thetas_rad = np.deg2rad(azimuth_angles) + P = len(thetas_rad) + scanning_vectors = np.zeros((self.elements, P), dtype=complex) + + for i in range(P): + scanning_vectors[:, i] = np.exp( + 1j * self.k * (self.x * np.sin(thetas_rad[i]) + self.y * np.cos(thetas_rad[i])) + ) + + return scanning_vectors + + @pyaedt_function_handler() + def bartlett( + self, data: np.ndarray, scanning_vectors: np.ndarray, range_bins: int = None, cross_range_bins: int = None + ): + """ + Estimate the direction of arrival (DoA) using the Bartlett (classical beamforming) method. + + Parameters + ---------- + data : np.ndarray + Complex-valued array of shape (range_bins, elements), typically output from range FFT. + Each row represents the antenna data for a specific range bin. + scanning_vectors : np.ndarray + Complex matrix of shape (elements, num_angles), where each column corresponds to + a scanning vector for a different azimuth/elevation angle. + range_bins : int, optional + Number of range bins (rows of the output), defaults to the first dimension of `data`. + cross_range_bins : int, optional + Number of cross-range (angular) bins, defaults to the second dimension of `scanning_vectors`. + + Returns + ------- + np.ndarray + 2D complex-valued array of shape (range_bins, cross_range_bins), representing the + power angular density (PAD) for each range bin and angle. + """ + + if range_bins is None: + range_bins = data.shape[0] + if cross_range_bins is None: + cross_range_bins = scanning_vectors.shape[1] + + scale_factor = scanning_vectors.shape[1] / cross_range_bins + pad_output = np.zeros((range_bins, cross_range_bins), dtype=complex) + + for n, range_bin_data in enumerate(data): + range_bin_data = np.reshape(range_bin_data, (1, self.elements)) + correlation_matrix = np.dot(range_bin_data.T, range_bin_data.conj()) + + if correlation_matrix.shape[0] != correlation_matrix.shape[1]: + raise ValueError("Correlation matrix is not square.") + if correlation_matrix.shape[0] != scanning_vectors.shape[0]: + raise ValueError("Dimension mismatch between correlation matrix and scanning vectors.") + + pad = np.zeros(scanning_vectors.shape[1], dtype=complex) + for i in range(scanning_vectors.shape[1]): + steering_vector = scanning_vectors[:, i] + pad[i] = steering_vector.conj().T @ correlation_matrix @ steering_vector + + pad_output[n] = pad * scale_factor + + return pad_output + + def capon( + self, data: np.ndarray, scanning_vectors: np.ndarray, range_bins: int = None, cross_range_bins: int = None + ) -> np.ndarray: + """ + Estimate the direction of arrival using the Capon (Minimum variance distortion less response) + beamforming method. + + Parameters + ---------- + data : np.ndarray + Complex-valued array of shape (range_bins, elements), typically output from range FFT. + Each row represents the antenna data for a specific range bin. + scanning_vectors : np.ndarray + Complex matrix of shape (elements, num_angles), where each column corresponds to + a scanning vector for a different azimuth/elevation angle. + range_bins : int, optional + Number of range bins (rows of the output), defaults to the first dimension of `data`. + cross_range_bins : int, optional + Number of cross-range (angular) bins, defaults to the second dimension of `scanning_vectors`. + + Returns + ------- + np.ndarray + 2D real-valued array of shape (range_bins, cross_range_bins), representing the + Capon spatial spectrum (inverse of interference power) for each range bin and angle. + """ + + if range_bins is None: + range_bins = data.shape[0] + if cross_range_bins is None: + cross_range_bins = scanning_vectors.shape[1] + + scale_factor = scanning_vectors.shape[1] / cross_range_bins + spectrum_output = np.zeros((range_bins, cross_range_bins), dtype=float) + + for n, range_bin_data in enumerate(data): + range_bin_data = np.reshape(range_bin_data, (1, self.elements)) + R = range_bin_data.T @ range_bin_data.conj() + + if R.shape[0] != R.shape[1]: + raise ValueError("Correlation matrix is not square.") + if R.shape[0] != scanning_vectors.shape[0]: + raise ValueError("Dimension mismatch between correlation matrix and scanning vectors.") + + try: + R_inv = np.linalg.inv(R) + except np.linalg.LinAlgError: + raise ValueError("Correlation matrix is singular or ill-conditioned.") + + for i in range(cross_range_bins): + sv = scanning_vectors[:, i] + denom = np.conj(sv).T @ R_inv @ sv + spectrum_output[n, i] = scale_factor / np.real(denom) + + return spectrum_output + + @pyaedt_function_handler() + def music( + self, + data: np.ndarray, + scanning_vectors: np.ndarray, + signal_dimension: int, + range_bins: int = None, + cross_range_bins: int = None, + ) -> np.ndarray: + """ + Estimate the direction of arrival (DoA) using the MUSIC method. + + Parameters + ---------- + data : np.ndarray + Complex-valued array of shape (range_bins, elements), typically output from range FFT. + Each row represents the antenna data for a specific range bin. + scanning_vectors : np.ndarray + Matrix of shape (elements, num_angles), where each column is a steering vector for a test angle. + signal_dimension : int + Number of sources/signals (model order). + range_bins : int, optional + Number of range bins to process. Defaults to `data.shape[0]`. + cross_range_bins : int, optional + Number of angle bins (scan directions). Defaults to `scanning_vectors.shape[1]`. + + Returns + ------- + np.ndarray + 2D real-valued array of shape (range_bins, cross_range_bins), + representing the MUSIC spectrum for each range bin and angle. + """ + if range_bins is None: + range_bins = data.shape[0] + if cross_range_bins is None: + cross_range_bins = scanning_vectors.shape[1] + + output = np.zeros((range_bins, cross_range_bins), dtype=float) + + for n, snapshot in enumerate(data): + snapshot = snapshot.reshape((1, self.elements)) + R = np.dot(snapshot.T, snapshot.conj()) + + if R.shape[0] != R.shape[1]: + raise ValueError("Correlation matrix is not square.") + if R.shape[0] != scanning_vectors.shape[0]: + raise ValueError("Dimension mismatch between correlation matrix and scanning vectors.") + + try: + eigenvalues, eigenvectors = np.linalg.eigh(R) + except np.linalg.LinAlgError: + raise np.linalg.LinAlgError("Failed to compute eigendecomposition (singular matrix).") + + M = R.shape[0] + noise_dim = M - signal_dimension + idx = np.argsort(eigenvalues) + En = eigenvectors[:, idx[:noise_dim]] # Noise subspace + + spectrum = np.zeros(cross_range_bins, dtype=float) + for i in range(cross_range_bins): + sv = scanning_vectors[:, i] + denom = np.abs(sv.conj().T @ En @ En.conj().T @ sv) + spectrum[i] = 0.0 if denom == 0 else 1.0 / denom + + output[n] = spectrum + + return output + + @pyaedt_function_handler() + def plot_angle_of_arrival( + self, + signal: np.ndarray, + doa_method: str = None, + field_of_view=None, + quantity_format: str = None, + title: str = "Angle of Arrival", + output_file: str = None, + show: bool = True, + show_legend: bool = True, + plot_size: tuple = (1920, 1440), + figure=None, + ): + """Create angle of arrival plot. + + Parameters + ---------- + signal : np.ndarray + Frame number. The default is ``None``, in which case all frames are used. + doa_method : str, optional + Method used for direction of arrival estimation. + Available options are: ``"Bartlett"``, ``"Capon"``, and ``"Music"``. + The default is ``None``, in which case ``"Bartlett"`` is selected. + field_of_view : np.ndarray, optional + Azimuth angular span in degrees to plot. The default is from -90 to 90 dregress. + quantity_format : str, optional + Conversion data function. The default is ``None``. + Available functions are: ``"abs"``, ``"ang"``, ``"dB10"``, ``"dB20"``, ``"deg"``, ``"imag"``, ``"norm"``, + and ``"real"``. + title : str, optional + Title of the plot. The default is ``"Range profile"``. + output_file : str or :class:`pathlib.Path`, optional + Full path for the image file. The default is ``None``, in which case an image in not exported. + show : bool, optional + Whether to show the plot. The default is ``True``. + If ``False``, the Matplotlib instance of the plot is shown. + show_legend : bool, optional + Whether to display the legend or not. The default is ``True``. + plot_size : tuple, optional + Image size in pixel (width, height). + figure : :class:`matplotlib.pyplot.Figure`, optional + An existing Matplotlib `Figure` to which the plot is added. + If not provided, a new `Figure` and `Axes` objects are created. + Default is ``None``. + + Returns + ------- + :class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter` + PyAEDT matplotlib figure object. + + Examples + -------- + >>> from ansys.aedt.core.visualization.advanced.doa import DirectionOfArrival + >>> import numpy as np + >>> freq = 10e9 + >>> signal_angle = 30 + >>> num_elements_x = 4 + >>> num_elements_y = 4 + >>> d = 0.015 + >>> x = np.tile(np.arange(num_elements_x) * d, num_elements_y) + >>> y = np.repeat(np.arange(num_elements_y) * d, num_elements_x) + >>> k = 2 * np.pi * freq / 3e8 + >>> signal_vector = np.exp( + ... 1j * k * (x * np.sin(np.radians(signal_angle)) + y * np.cos(np.radians(signal_angle))) + ... ) + >>> signal_snapshot = signal_vector + 0.1 * ( + ... np.random.randn(len(signal_vector)) + 1j * np.random.randn(len(signal_vector)) + ... ) + >>> doa = DirectionOfArrival(x, y, freq) + >>> doa.plot_angle_of_arrival(signal_snapshot) + >>> doa.plot_angle_of_arrival(signal_snapshot, doa_method="MUSIC") + """ + + data = np.array([signal]) + + if field_of_view is None: + field_of_view = np.linspace(-90, 90, 181) + + if doa_method is None: + doa_method = "Bartlett" + + scanning_vectors = self.get_scanning_vectors(field_of_view) + + if doa_method.lower() == "bartlett": + output = self.bartlett(data, scanning_vectors) + elif doa_method.lower() == "capon": + output = self.capon(data, scanning_vectors) + elif doa_method.lower() == "music": + output = self.music(data, scanning_vectors, 1) + else: + raise ValueError(f"Unknown {doa_method} method.") + + if quantity_format is None: + quantity_format = "dB20" + + output = conversion_function(output, quantity_format) + + new = ReportPlotter() + new.show_legend = show_legend + new.title = title + new.size = plot_size + + x = field_of_view + y = output.T + + legend = f"DoA {doa_method}" + curve = [x.tolist(), y.tolist(), legend] + + # Single plot + props = {"x_label": "Azimuth (°)", "y_label": "Power"} + name = curve[2] + new.add_trace(curve[:2], 0, props, name) + new.x_margin_factor = 0.0 + new.y_margin_factor = 0.2 + _ = new.plot_2d(None, output_file, show, figure=figure) + return new diff --git a/src/ansys/aedt/core/visualization/advanced/frtm_visualization.py b/src/ansys/aedt/core/visualization/advanced/frtm_visualization.py index 61bd218e838..36bdbd817a6 100644 --- a/src/ansys/aedt/core/visualization/advanced/frtm_visualization.py +++ b/src/ansys/aedt/core/visualization/advanced/frtm_visualization.py @@ -31,6 +31,7 @@ from ansys.aedt.core.generic.constants import SpeedOfLight from ansys.aedt.core.generic.general_methods import conversion_function from ansys.aedt.core.generic.general_methods import pyaedt_function_handler +from ansys.aedt.core.visualization.advanced.doa import DirectionOfArrival from ansys.aedt.core.visualization.plot.matplotlib import ReportPlotter current_python_version = sys.version_info[:2] @@ -99,12 +100,14 @@ def __init__(self, input_file): self.__antenna_names = None self.__channel_number = None self.__coupling_combos = None + self.__receiver_position = {} self.__channel_names = [] self.__all_data = {} - self.__data_conversion_function = "dB20" - + self.__data_conversion_function = None self.__read_frtm() + self.__receiver_position = {channel: [0.0, 0.0] for channel in self.channel_names} + @property def dlxcd_version(self): """DlxCd version.""" @@ -250,6 +253,16 @@ def channel_names(self): """Names assigned to radar channels.""" return self.__channel_names + @property + def receiver_position(self): + """Position of receivers respected the transmitters.""" + return self.__receiver_position + + @receiver_position.setter + def receiver_position(self, value): + """Position of receivers respected the transmitters.""" + self.__receiver_position = value + @property def all_data(self): """Complete dataset.""" @@ -311,9 +324,103 @@ def data_conversion_function(self, val): self.__data_conversion_function = val @pyaedt_function_handler() - def range_profile( - self, data: np.ndarray, oversampling: int = 1, window: str = None, window_size: int = None - ) -> np.ndarray: + def get_data_pulse(self, pulse: int = None) -> np.ndarray: + """ + Get the data for a specified pulse. + + Parameters + ---------- + pulse: int, optional + Number of points to window. The default is ``None``. + + Returns + ------- + numpy.ndarray + Data for specified pulse. + + Examples + -------- + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMData + >>> file = "RxSignal.frtm" + >>> data = RangeDopplerData(file) + >>> pulse_number = data.cpi_frames + >>> data_pulse = data.get_data_pulse(0) + """ + + if pulse is None: + pulse = int(self.cpi_frames / 2) + elif pulse > self.cpi_frames: + raise ValueError(f"Pulse must be less than {self.cpi_frames}.") + else: + pulse = int(pulse) + + data_array = np.stack(list(self.all_data.values())) + pulse_data = data_array[:, pulse] + return pulse_data + + @pyaedt_function_handler() + def convert_frequency_range(self, pulse: int = None, window: str = None, size: int = None) -> np.ndarray: + """ + Convert frequency domain radar data to range domain using IFFT with optional windowing and resampling. + + This method applies a window to the frequency-domain radar data, scales it for energy preservation, + and then computes the IFFT to convert to range domain. It supports optional up and down-sampling + to a specified size. + + pulse: int, optional + Index of the pulse to extract. + The default is ``None`` in which case the center pulse (middle index) is used. + window: str, optional + Type of window. The default is ``None``. Available options are ``"Hann"``, ``"Hamming"``, and ``"Flat"``. + size: int, optional + Output number of samples. The default is ``None``. + + Returns + ------- + numpy.ndarray + Range domain data. + + Examples + -------- + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMData + >>> file = "RxSignal.frtm" + >>> data = RangeDopplerData(file) + >>> data_range = data.convert_frequency_range() + """ + + data_conversion_function_original = self.data_conversion_function + self.data_conversion_function = None + + data_pulse = self.get_data_pulse(pulse) + + data_size = int(data_pulse.shape[1]) + + if size is None: + size = data_size + + if window: + window_function = self.window_function(window, size) + window_function_sum = np.sum(window_function) + sampling_factor = len(window_function) / window_function_sum + window_function = window_function * sampling_factor + new_data = np.multiply(data_pulse, window_function) + else: + new_data = data_pulse + + up_sample = size / data_size + + channel_range = up_sample * np.fft.ifft(new_data, n=size) + channel_range = np.fliplr(channel_range) + + # Convert data to original function + self.data_conversion_function = data_conversion_function_original + if data_conversion_function_original is not None: + channel_range = conversion_function(channel_range, self.data_conversion_function) + + return channel_range + + @pyaedt_function_handler() + def range_profile(self, data: np.ndarray, window: str = None, size: int = None) -> np.ndarray: """ Calculate the range profile of a specific CPI frame. @@ -321,39 +428,49 @@ def range_profile( ---------- data : numpy.ndarray Array of complex samples with ``frequency_number`` elements. - oversampling: int - Oversampling factor. The default is ``1``. window: str, optional Type of window. The default is ``None``. Available options are ``"Hann"``, ``"Hamming"``, and ``"Flat"``. - window_size: int, optional - Number of points to window. The default is ``None``. + size: int, optional + Output number of samples. The default is ``None``. Returns ------- numpy.ndarray Range profile data. + + Examples + -------- + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMData + >>> file = "RxSignal.frtm" + >>> data = RangeDopplerData(file) + >>> channel_name = data.channel_names[0] + >>> data_channel_1 = data.all_data[channel_name] + >>> data_pulse_0 = data_channel_1[0] + >>> range_profile = data.range_profile(data_pulse_0) """ data_conversion_function_original = self.data_conversion_function self.data_conversion_function = None - if window_size is None: - window_size = data.size - elif len(data) >= window_size: - # Crop data - data = data[:window_size] - else: - # Padded data - padded_data = np.zeros(window_size, dtype=data.dtype) - padded_data[: len(data)] = data - data = padded_data + data_size = int(np.shape(np.squeeze(data))[0]) + + if size is None: + size = data_size + if window: - win_range, _ = self.window_function(window, window_size) - data = data * win_range + window_function = self.window_function(window, data_size) + window_function_sum = np.sum(window_function) + sampling_factor = data_size / window_function_sum + window_function = window_function * sampling_factor + new_data = np.multiply(data, window_function) + else: + new_data = data + + up_sample = size / data_size # FFT with oversampling - n_fft = window_size * oversampling - range_profile_data = oversampling * np.fft.ifft(data, n=n_fft) + range_profile_data = up_sample * np.fft.ifft(new_data, n=size) + # Convert data to original function self.data_conversion_function = data_conversion_function_original if data_conversion_function_original is not None: range_profile_data = conversion_function(range_profile_data, self.data_conversion_function) @@ -386,10 +503,19 @@ def range_doppler( Range doppler array of shape (doppler_bins, range_bins), where: - Each column corresponds to a Doppler velocity bin. - Each row corresponds to a range bin. + + Examples + -------- + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMData + >>> file = "RxSignal.frtm" + >>> data = RangeDopplerData(file) + >>> channel_name = data.channel_names[0] + >>> range_doppler = data.range_doppler(channel_name) """ if channel is None: channel = self.channel_names[0] + # Data must be complex original_function = self.data_conversion_function self.data_conversion_function = None @@ -403,30 +529,147 @@ def range_doppler( if range_bins is None: range_bins = num_freq - range_profile_cpi_frame = np.zeros((doppler_bins, range_bins), dtype=complex) - data_range_pulse_out = np.zeros((range_bins, doppler_bins), dtype=complex) - - for n, p in enumerate(data[:doppler_bins]): - rp = self.range_profile(p, window=window, oversampling=1, window_size=range_bins) - range_profile_cpi_frame[n] = rp + # range_profile_cpi_frame = np.zeros((doppler_bins, range_bins), dtype=complex) + # data_range_pulse_out = np.zeros((range_bins, doppler_bins), dtype=complex) + # + # for n, p in enumerate(data[:doppler_bins]): + # rp = self.range_profile(p, window=window, oversampling=1, window_size=range_bins) + # range_profile_cpi_frame[n] = rp # Place doppler as first dimension - range_profile_cpi_frame = np.swapaxes(range_profile_cpi_frame, 0, 1) + data = np.swapaxes(data, 0, 1) + # Swap first and second half to place zero at first index - data_range_pulse_flip = np.fliplr(range_profile_cpi_frame) + data = np.fliplr(data) - # Window over doppler axis - win_doppler, _ = self.window_function(window, doppler_bins) + # Doppler windowing + doppler_window = self.window_function(window, num_cpi_frames) + sample_factor_doppler = len(doppler_window) / np.sum(doppler_window) + up_sample_doppler = doppler_bins / num_cpi_frames - for r, pulse in enumerate(data_range_pulse_flip): - pulse_f_win = np.multiply(pulse, win_doppler) - pulse_t = np.fft.ifftshift(np.fft.ifft(pulse_f_win, n=doppler_bins)) - data_range_pulse_out[r] = pulse_t + # Range windowing + range_window = self.window_function(window, num_freq) + sample_factor_range = len(range_window) / np.sum(range_window) + up_sample_range = range_bins / num_freq + + doppler_window = doppler_window * sample_factor_doppler + range_window = range_window * sample_factor_range + + fp_win = up_sample_doppler * np.multiply(data, doppler_window) + s1 = np.fft.ifft(fp_win, n=doppler_bins) + s1 = np.rot90(s1) + + s1_win = up_sample_range * np.multiply(range_window, s1) + s2 = np.fft.ifft(s1_win, n=range_bins) + s2 = np.rot90(s2) + s2_shift = np.fft.fftshift(s2, axes=1) + + range_doppler = np.flipud(s2_shift) self.data_conversion_function = original_function if original_function is not None: - data_range_pulse_out = conversion_function(data_range_pulse_out, self.data_conversion_function) - return data_range_pulse_out + range_doppler = conversion_function(range_doppler, self.data_conversion_function) + return range_doppler + + @pyaedt_function_handler() + def range_angle_map( + self, + pulse: int = None, + window: str = None, + range_bins: int = None, + cross_range_bins: int = None, + doa_method: str = None, + field_of_view=None, + range_bin_index: int = None, + ) -> np.ndarray: + """ + Compute the range-angle map using direction of arrival estimation methods. + + Parameters + ---------- + pulse: int, optional + Index of the pulse to extract. + The default is ``None`` in which case the center pulse (middle index) is used. + window: str, optional + Type of window. The default is ``None``. Available options are ``"Hann"``, ``"Hamming"``, and ``"Flat"``. + range_bins : int, optional + Number of bins to use in the range (frequency) dimension. If ``None``, number of channels is used. + cross_range_bins : int, optional + Number of bins in the angular (azimuth) dimension. If ``None``, ``181`` bins are used. + doa_method : str, optional + Direction of arrival estimation method. Options are ``"Bartlett"``, ``"Capon"``, and ``"MUSIC"``. + The default is ``"Bartlett"``. + field_of_view : list, optional + Azimuth angular span in degrees to analyze. The default is ``[-90, 90]``. + range_bin_index : int, optional + Specific range bin index to extract the angular profile. If provided, only that bin is used. + + Returns + ------- + np.ndarray + Data representing the range-angle intensity map. + + Examples + -------- + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMData + >>> file = "RxSignal.frtm" + >>> data = RangeDopplerData(file) + >>> range_angle_map = data.range_angle_map() + """ + if field_of_view is None: + field_of_view = [-90, 90] + + if range_bins is None: + range_bins = self.frequency_number + + if cross_range_bins is None: + cross_range_bins = 181 + + # Data must be complex + original_function = self.data_conversion_function + self.data_conversion_function = None + + if doa_method is None: + doa_method = "Bartlett" + + ch_range = self.convert_frequency_range(window=window, size=range_bins, pulse=pulse) + ang_stop = field_of_view[1] + 90 + ang_start = field_of_view[0] + 90 + range_ch = np.swapaxes(ch_range, 0, 1) + + if range_bin_index is not None: + range_ch = np.atleast_2d(range_ch[range_bin_index]) + range_bins = 1 + + x_position = [position[0] for position in self.receiver_position.values()] + y_position = [position[1] for position in self.receiver_position.values()] + + doa = DirectionOfArrival(x_position=x_position, y_position=y_position, frequency=self.frequency_center) + + # Scanning vectors + incident_azimuth_angles = np.linspace(ang_start, ang_stop, num=cross_range_bins) + scanning_vectors = doa.get_scanning_vectors(incident_azimuth_angles) + + if doa_method.lower() == "bartlett": + rng_xrng = doa.bartlett(range_ch, scanning_vectors, range_bins, cross_range_bins) + elif doa_method.lower() == "capon": + rng_xrng = doa.capon(range_ch, scanning_vectors, range_bins, cross_range_bins) + elif doa_method.lower() == "music": + rng_xrng = doa.music( + data=range_ch, + scanning_vectors=scanning_vectors, + range_bins=range_bins, + cross_range_bins=cross_range_bins, + signal_dimension=1, + ) + else: + raise ValueError(f"DoA method {doa_method} not supported.") + + rng_xrng = np.flipud(rng_xrng) + self.data_conversion_function = original_function + if original_function is not None: + rng_xrng = conversion_function(rng_xrng, self.data_conversion_function) + return rng_xrng @staticmethod def window_function(window="Flat", size=512): @@ -441,8 +684,15 @@ def window_function(window="Flat", size=512): Returns ------- - tuple - Data windowed and data sum. + numpy.ndarray + The window with the maximum value normalized to one. + + Examples + -------- + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMData + >>> file = "RxSignal.frtm" + >>> data = RangeDopplerData(file) + >>> window = data.window_function("Hann") """ if window is None or window == "Flat": win = np.ones(size) @@ -452,10 +702,7 @@ def window_function(window="Flat", size=512): win = np.hamming(size) else: raise ValueError(f"Window function {window} not supported.") - - win_sum = np.sum(win) - win *= size / win_sum - return win, win_sum + return win def __read_frtm(self): string_to_stop_reading_header = "@ BeginData" @@ -657,14 +904,14 @@ def plot_range_profile( channel: str = None, frame: int = None, cpi_frame: int = None, - oversampling: int = 1, window: str = None, - window_size: int = None, + size: int = None, + quantity_format: str = None, title: str = "Range profile", output_file: str = None, show: bool = True, show_legend: bool = True, - size: tuple = (1920, 1440), + plot_size: tuple = (1920, 1440), animation: bool = True, figure=None, ): @@ -678,12 +925,14 @@ def plot_range_profile( Frame number. The default is ``None``, in which case all frames are used. cpi_frame : int, optional Cpi frame number. The default is ``None``, in which case the middle cpi frame is used. - oversampling: int - Oversampling factor. The default is ``1``. window: str, optional Type of window. The default is ``None``. Available options are ``"Hann"``, ``"Hamming"``, and ``"Flat"``. - window_size: int, optional - Number of points to window. The default is ``None``. + size: int, optional + Output number of samples. The default is ``None``. + quantity_format : str, optional + Conversion data function. The default is ``None``. + Available functions are: ``"abs"``, ``"ang"``, ``"dB10"``, ``"dB20"``, ``"deg"``, ``"imag"``, ``"norm"``, + and ``"real"``. title : str, optional Title of the plot. The default is ``"Range profile"``. output_file : str or :class:`pathlib.Path`, optional @@ -693,7 +942,7 @@ def plot_range_profile( If ``False``, the Matplotlib instance of the plot is shown. show_legend : bool, optional Whether to display the legend or not. The default is ``True``. - size : tuple, optional + plot_size : tuple, optional Image size in pixel (width, height). animation : bool, optional Create an animated plot or overlap the frames. The default is ``True``. @@ -706,6 +955,22 @@ def plot_range_profile( ------- :class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter` PyAEDT matplotlib figure object. + + Examples + -------- + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import get_results_files + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMData + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMPlotter + >>> output_directory = "directory.results" + >>> frames_dict = get_results_files(directory) + >>> for frame, data_frame in frames_dict.items(): + >>> doppler_data = FRTMData(data_frame) + >>> data[frame] = doppler_data + >>> frtm_plotter = FRTMPlotter(data) + >>> frame_number = frtm_plotter.frames[0] + >>> frtm_plotter.plot_range_profile(frame=frame_number) + >>> frtm_plotter.plot_range_profile(output_file="range_profile.gif", animation=True, show=False) + >>> frtm_plotter.plot_range_profile(animation=False) """ all_data = self.all_data if frame is not None: @@ -715,7 +980,7 @@ def plot_range_profile( new = ReportPlotter() new.show_legend = show_legend new.title = title - new.size = size + new.size = plot_size for frame, data in all_data.items(): if channel is not None and channel not in data.channel_names: raise ValueError(f"Channel {channel} not found in data.") @@ -727,11 +992,18 @@ def plot_range_profile( elif cpi_frame >= (data.cpi_frames - 1): raise ValueError(f"Chirp {cpi_frame} is out of range.") - data_range_profile = data.range_profile( - data.all_data[channel][cpi_frame], oversampling=oversampling, window_size=window_size, window=window - ) + data_pulse = data.all_data[channel][cpi_frame] + + if data.data_conversion_function is None: + if quantity_format is None: + data.data_conversion_function = "dB10" + else: + data.data_conversion_function = quantity_format + + data_range_profile = data.range_profile(data_pulse, size=size, window=window) x = np.linspace(0, data.range_maximum, np.shape(data_range_profile)[0]) + y = data_range_profile legend = f"Frame {frame}, CPI {cpi_frame}" @@ -768,6 +1040,7 @@ def plot_range_doppler( range_bins: int = None, doppler_bins: int = None, window: str = None, + quantity_format: str = None, title: str = "Doppler Velocity-Range", output_file: str = None, show: bool = True, @@ -791,6 +1064,10 @@ def plot_range_doppler( If not specified, uses the original number of CPI frames. window: str, optional Type of window. The default is ``None``. Available options are ``"Hann"``, ``"Hamming"``, and ``"Flat"``. + quantity_format : str, optional + Conversion data function. The default is ``None``. + Available functions are: ``"abs"``, ``"ang"``, ``"dB10"``, ``"dB20"``, ``"deg"``, ``"imag"``, ``"norm"``, + and ``"real"``. title : str, optional Title of the plot. The default is ``"Range profile"``. output_file : str or :class:`pathlib.Path`, optional @@ -811,6 +1088,21 @@ def plot_range_doppler( ------- :class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter` PyAEDT matplotlib figure object. + + Examples + -------- + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import get_results_files + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMData + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMPlotter + >>> output_directory = "directory.results" + >>> frames_dict = get_results_files(directory) + >>> for frame, data_frame in frames_dict.items(): + >>> doppler_data = FRTMData(data_frame) + >>> data[frame] = doppler_data + >>> frtm_plotter = FRTMPlotter(data) + >>> frame_number = frtm_plotter.frames[0] + >>> frtm_plotter.plot_range_doppler(frame=frame_number) + >>> frtm_plotter.plot_range_doppler(output_file="range_doppler.gif", animation=True, show=False) """ all_data = self.all_data if frame is not None: @@ -827,6 +1119,13 @@ def plot_range_doppler( elif channel is None: channel = data.channel_names[0] + # Complex data can not be plotted, so it is converted if needed + if data.data_conversion_function is None: + if quantity_format is None: + data.data_conversion_function = "dB20" + else: + data.data_conversion_function = quantity_format + data_range_profile = data.range_doppler( channel=channel, range_bins=range_bins, doppler_bins=doppler_bins, window=window ) @@ -881,6 +1180,199 @@ def plot_range_doppler( ) return new + @pyaedt_function_handler() + def plot_range_angle_map( + self, + frame: int = None, + pulse: int = None, + window: str = None, + range_bins: int = None, + cross_range_bins: int = None, + doa_method: str = None, + field_of_view=None, + dynamic_range=None, + quantity_format: str = None, + polar: bool = False, + title: str = "Angle vs Range (Azimuth)", + output_file: str = None, + show: bool = True, + show_legend: bool = True, + size: tuple = (1920, 1440), + figure=None, + ): + """Create range-angle map contour plot. + + Parameters + ---------- + frame : int, optional + Frame number. The default is ``None``, in which case all frames are used. + pulse: int, optional + Index of the pulse to extract. + The default is ``None`` in which case the center pulse (middle index) is used. + window: str, optional + Type of window. The default is ``None``. Available options are ``"Hann"``, ``"Hamming"``, and ``"Flat"``. + range_bins : int, optional + Number of bins to use in the range (frequency) dimension. If ``None``, number of channels is used. + cross_range_bins : int, optional + Number of bins in the angular (azimuth) dimension. If ``None``, ``181`` bins are used. + doa_method : str, optional + Method used for direction of arrival estimation. + Available options are: ``"Bartlett"``, ``"Capon"``, and ``"Music"``. + The default is ``None``, in which case ``"Bartlett"`` is selected. + field_of_view : list, optional + Azimuth angular span in degrees to plot. The default is ``[-90, 90]``. + dynamic_range : float, optional + Dynamic range in `dB`. + If provided, the color map is clipped between the max power and `max - dynamic_range`. + quantity_format : str, optional + Conversion data function. The default is ``None``. + Available functions are: ``"abs"``, ``"ang"``, ``"dB10"``, ``"dB20"``, ``"deg"``, ``"imag"``, ``"norm"``, + and ``"real"``. + polar : bool, optional + Generate the plot in polar coordinates. The default is ``True``. If ``False``, the plot + generated is rectangular. + title : str, optional + Title of the plot. The default is ``"Range profile"``. + output_file : str or :class:`pathlib.Path`, optional + Full path for the image file. The default is ``None``, in which case an image in not exported. + show : bool, optional + Whether to show the plot. The default is ``True``. + If ``False``, the Matplotlib instance of the plot is shown. + show_legend : bool, optional + Whether to display the legend or not. The default is ``True``. + size : tuple, optional + Image size in pixel (width, height). + figure : :class:`matplotlib.pyplot.Figure`, optional + An existing Matplotlib `Figure` to which the plot is added. + If not provided, a new `Figure` and `Axes` objects are created. + Default is ``None``. + + Returns + ------- + :class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter` + PyAEDT matplotlib figure object. + + Examples + -------- + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import get_results_files + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMData + >>> from ansys.aedt.core.visualization.advanced.frtm_visualization import FRTMPlotter + >>> output_directory = "directory.results" + >>> frames_dict = get_results_files(directory) + >>> for frame, data_frame in frames_dict.items(): + >>> doppler_data = FRTMData(data_frame) + >>> data[frame] = doppler_data + >>> frtm_plotter = FRTMPlotter(data) + >>> frame_number = frtm_plotter.frames[0] + >>> frtm_plotter.plot_range_angle_map(frame=frame_number) + >>> frtm_plotter.plot_range_angle_map(output_file="range_angle_map.gif", animation=True, show=False) + """ + all_data = self.all_data + + if frame is not None: + all_data = {frame: self.all_data[frame]} + + new = ReportPlotter() + new.show_legend = show_legend + new.title = title + new.size = size + + if field_of_view is None: + field_of_view = [-90, 90] + + if cross_range_bins is None: + cross_range_bins = 181 + + levels = 64 + normalize = None + + for frame, data in all_data.items(): + # Complex data can not be plotted, so it is converted if needed + if data.data_conversion_function is None: + if quantity_format is None: + data.data_conversion_function = "dB20" + else: + data.data_conversion_function = quantity_format + + if range_bins is None: + range_bins = data.frequency_number + + data_range_profile = data.range_angle_map( + pulse=pulse, + window=window, + range_bins=range_bins, + cross_range_bins=cross_range_bins, + doa_method=doa_method, + field_of_view=field_of_view, + ) + + range_vals = np.linspace(0, data.range_maximum, num=range_bins) + azimuth_vals = np.linspace(field_of_view[0], field_of_view[1], num=cross_range_bins) + + r, theta = np.meshgrid(range_vals, azimuth_vals) + + max_power = np.max(data_range_profile) + + if dynamic_range is not None: + # Normalize plot to maximum value so values stay constant for animation + min_power = max_power - dynamic_range + levels = np.linspace(min_power, max_power, 64) + normalize = [min_power, max_power] + + if polar: + plot_data = [data_range_profile, r.T, np.radians(theta.T)] + else: + plot_data = [data_range_profile, r.T, theta.T] + + ylabel = "Range (m)" + xlabel = "Azimuth (degrees)" + + legend = f"Frame {frame}" + + if len(all_data) == 1: + # Single plot + props = { + "x_label": xlabel, + "y_label": ylabel, + } + new.add_trace(plot_data, 0, props, legend) + + _ = new.plot_contour( + trace=0, + snapshot_path=output_file, + show=show, + figure=figure, + is_spherical=False, + polar=polar, + color_bar="Power", + levels=levels, + normalize=normalize, + max_theta=field_of_view[1], + min_theta=field_of_view[0], + ) + return new + else: + props = { + "x_label": xlabel, + "y_label": ylabel, + } + new.add_trace(plot_data, 0, props, legend) + + new.animate_contour( + trace=None, + polar=polar, + levels=levels, + max_theta=field_of_view[1], + min_theta=field_of_view[0], + color_bar=None, + snapshot_path=output_file, + show=show, + figure=figure, + is_spherical=False, + normalize=normalize, + ) + return new + @pyaedt_function_handler() def get_results_files(input_dir, var_name="time_var"): diff --git a/src/ansys/aedt/core/visualization/plot/matplotlib.py b/src/ansys/aedt/core/visualization/plot/matplotlib.py index e19e0de9a83..1cc9edfc66b 100644 --- a/src/ansys/aedt/core/visualization/plot/matplotlib.py +++ b/src/ansys/aedt/core/visualization/plot/matplotlib.py @@ -1388,6 +1388,7 @@ def plot_contour( show=True, figure=None, is_spherical=True, + normalize=None, ): """Create a Matplotlib figure contour based on a list of data. @@ -1419,6 +1420,9 @@ def plot_contour( If not provided, a new `Figure` and `Axes` object are created. is_spherical : bool, optional Whether to use spherical or cartesian data. + normalize : list, optional + Normalize the color scale using the provided ``[vmin, vmax]`` values. + If not provided or invalid, automatic normalization is applied. Returns ------- @@ -1430,6 +1434,7 @@ def plot_contour( return False else: tr = tr[0] + projection = "polar" if polar else "rectilinear" if not figure: @@ -1444,20 +1449,27 @@ def plot_contour( self.ax.set_rticks(np.linspace(min_theta, max_theta, 3)) self.ax.set_theta_zero_location("N") self.ax.set_theta_direction(-1) + self.ax.set_thetamin(min_theta) + self.ax.set_thetamax(max_theta) else: self.ax.set_ylabel(tr.y_label) self.ax.set(title=self.title) - ph = tr._spherical_data[2] - th = tr._spherical_data[1] - data_to_plot = tr._spherical_data[0] if not is_spherical: ph = tr._cartesian_data[2] th = tr._cartesian_data[1] data_to_plot = tr._cartesian_data[0] + else: + ph = tr._spherical_data[2] + th = tr._spherical_data[1] + data_to_plot = tr._spherical_data[0] + + norm = None + if isinstance(normalize, list) and len(normalize) == 2: + norm = Normalize(vmin=normalize[0], vmax=normalize[1]) - contour = self.ax.contourf(ph, th, data_to_plot, levels=levels, cmap="jet") + contour = self.ax.contourf(ph, th, data_to_plot, levels=levels, cmap="jet", norm=norm, extend="both") if color_bar: cbar = self.fig.colorbar(contour, ax=self.ax) cbar.set_label(color_bar, rotation=270, labelpad=20) @@ -1545,6 +1557,7 @@ def animate_contour( show=True, figure=None, is_spherical=True, + normalize=None, ): """Create an animated Matplotlib figure contour based on a list of data. @@ -1576,6 +1589,9 @@ def animate_contour( If not provided, a new `Figure` and `Axes` object are created. is_spherical : bool, optional Whether to use spherical or cartesian data. + normalize : list, optional + Normalize the color scale using the provided ``[vmin, vmax]`` values. + If not provided or invalid, automatic normalization is applied. Returns ------- @@ -1605,18 +1621,25 @@ def update(i): self.ax.set_rticks(np.linspace(min_theta, max_theta, 3)) self.ax.set_theta_zero_location("N") self.ax.set_theta_direction(-1) + self.ax.set_thetamin(min_theta) + self.ax.set_thetamax(max_theta) else: self.ax.set_ylabel(trace.y_label) self.ax.set(title=self.title) - ph = trace._spherical_data[2] - th = trace._spherical_data[1] - data_to_plot = trace._spherical_data[0] if not is_spherical: ph = trace._cartesian_data[2] th = trace._cartesian_data[1] data_to_plot = trace._cartesian_data[0] + else: + ph = trace._spherical_data[2] + th = trace._spherical_data[1] + data_to_plot = trace._spherical_data[0] + + norm = None + if isinstance(normalize, list) and len(normalize) == 2: + norm = Normalize(vmin=normalize[0], vmax=normalize[1]) contour = self.ax.contourf( ph, @@ -1624,6 +1647,8 @@ def update(i): data_to_plot, levels=levels, cmap="jet", + norm=norm, + extend="both", ) if color_bar: cbar = self.fig.colorbar(contour, ax=self.ax) diff --git a/tests/system/visualization/test_FRTM_data_plotter.py b/tests/system/visualization/test_FRTM_data_plotter.py index 9afafd40b7b..24f8d9eb427 100644 --- a/tests/system/visualization/test_FRTM_data_plotter.py +++ b/tests/system/visualization/test_FRTM_data_plotter.py @@ -79,18 +79,16 @@ def test_window(self): metadata_file_pulse = results_files[0.0] frtm_pulse = FRTMData(input_file=metadata_file_pulse) - win_flat, win_flat_sum = frtm_pulse.window_function() + win_flat = frtm_pulse.window_function() assert len(win_flat) == 512 - assert win_flat_sum - win_han, win_han_sum = frtm_pulse.window_function("Hann", 128) + win_han = frtm_pulse.window_function("Hann", 128) assert len(win_han) == 128 - assert win_han_sum - win_hamming, win_hamming_sum = frtm_pulse.window_function("Hamming", 128) + win_hamming = frtm_pulse.window_function("Hamming", 128) assert len(win_hamming) == 128 - assert win_hamming_sum + # Data def test_frtm_data(self): with pytest.raises(FileNotFoundError, match="FRTM file does not exist."): FRTMData(input_file="invented") @@ -135,7 +133,7 @@ def test_frtm_data(self): assert frtm_pulse.range_maximum assert frtm_pulse.velocity_resolution assert frtm_pulse.velocity_maximum - assert frtm_pulse.data_conversion_function == "dB20" + assert frtm_pulse.data_conversion_function is None frtm_pulse.data_conversion_function = "abs" assert frtm_pulse.data_conversion_function == "abs" @@ -178,7 +176,7 @@ def test_frtm_data(self): assert frtm_chirp.range_maximum assert frtm_chirp.velocity_resolution assert frtm_chirp.velocity_maximum - assert frtm_chirp.data_conversion_function == "dB20" + assert frtm_chirp.data_conversion_function is None frtm_chirp.data_conversion_function = "abs" assert frtm_chirp.data_conversion_function == "abs" @@ -193,11 +191,11 @@ def test_range_profile(self): range_doppler_1 = frtm_data.range_profile(data_cpi_0) assert len(range_doppler_1) == 250 - range_doppler_2 = frtm_data.range_profile(data_cpi_0, window="Flat", window_size=128) + range_doppler_2 = frtm_data.range_profile(data_cpi_0, window="Flat", size=128) assert len(range_doppler_2) == 128 - range_doppler_3 = frtm_data.range_profile(data_cpi_0, window="Flat", window_size=512, oversampling=2) - assert len(range_doppler_3) == 1024 + range_doppler_3 = frtm_data.range_profile(data_cpi_0, window="Flat", size=512) + assert len(range_doppler_3) == 512 def test_range_doppler(self): results_files = get_results_files(self.input_dir_with_index) @@ -213,6 +211,52 @@ def test_range_doppler(self): range_doppler_data_3 = frtm_data.range_doppler(range_bins=512, doppler_bins=512) assert range_doppler_data_3.shape == (512, 512) + def test_data_pulse(self): + results_files = get_results_files(self.input_dir_with_index) + metadata_file_pulse = results_files[0.0] + frtm_data = FRTMData(input_file=metadata_file_pulse) + pulse_number = frtm_data.cpi_frames + + data_pulse_1 = frtm_data.get_data_pulse() + assert data_pulse_1.shape[1] == 250 + data_pulse_2 = frtm_data.get_data_pulse(1) + assert data_pulse_2.shape[1] == 250 + with pytest.raises(ValueError): + frtm_data.get_data_pulse(pulse_number + 1) + + def test_convert_frequency_range(self): + results_files = get_results_files(self.input_dir_with_index) + metadata_file_pulse = results_files[0.0] + frtm_data = FRTMData(input_file=metadata_file_pulse) + frtm_data.data_conversion_function = "dB20" + + data_range_1 = frtm_data.convert_frequency_range(window="Flat") + assert data_range_1.shape[1] == 250 + data_pulse_2 = frtm_data.convert_frequency_range() + assert data_pulse_2.shape[1] == 250 + with pytest.raises(ValueError): + frtm_data.convert_frequency_range(window="invented") + + def test_range_angle_map(self): + results_files = get_results_files(self.input_dir_with_index) + metadata_file_pulse = results_files[0.0] + frtm_data = FRTMData(input_file=metadata_file_pulse) + frtm_data.data_conversion_function = "dB20" + + data_1 = frtm_data.range_angle_map(doa_method=None, range_bin_index=1) + assert data_1.shape[1] == 181 + data_2 = frtm_data.range_angle_map(doa_method=None, range_bin_index=1, cross_range_bins=91) + assert data_2.shape[1] == 91 + data_3 = frtm_data.range_angle_map(doa_method="capon") + assert data_3.shape[0] == 250 + assert data_3.shape[1] == 181 + data_4 = frtm_data.range_angle_map(doa_method="music") + assert data_4.shape[0] == 250 + assert data_4.shape[1] == 181 + with pytest.raises(ValueError): + frtm_data.range_angle_map(doa_method="music2") + + # Plotter def test_plotter(self): results_files = get_results_files(self.input_dir_with_index) @@ -275,3 +319,20 @@ def test_range_doppler_plotter(self): # Overlap all plots range_doppler2 = frtm_plotter.plot_range_doppler(show=False, frame=frtm_plotter.frames[0]) assert isinstance(range_doppler2, ReportPlotter) + + def test_range_angle_map_plotter(self): + results_files = get_results_files(self.input_dir_with_index) + doppler_data_frames = {} + for frame, data_frame in results_files.items(): + doppler_data = FRTMData(data_frame) + doppler_data_frames[frame] = doppler_data + + frtm_plotter = FRTMPlotter(frtm_data=doppler_data_frames) + + # Animation plot + range_doppler1 = frtm_plotter.plot_range_angle_map(show=False, dynamic_range=100, polar=True) + assert isinstance(range_doppler1, ReportPlotter) + + # Overlap all plots + range_doppler2 = frtm_plotter.plot_range_angle_map(show=False, frame=frtm_plotter.frames[0]) + assert isinstance(range_doppler2, ReportPlotter) diff --git a/tests/system/visualization/test_doa.py b/tests/system/visualization/test_doa.py new file mode 100644 index 00000000000..c5b7553dcee --- /dev/null +++ b/tests/system/visualization/test_doa.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 - 2025 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import warnings + +import numpy as np +import pytest + +from ansys.aedt.core.internal.checks import ERROR_GRAPHICS_REQUIRED +from ansys.aedt.core.internal.checks import check_graphics_available +from ansys.aedt.core.visualization.advanced.doa import DirectionOfArrival + +try: + check_graphics_available() +except ImportError: + warnings.warn(ERROR_GRAPHICS_REQUIRED) + + +@pytest.fixture(scope="module", autouse=True) +def desktop(): + """Override the desktop fixture to DO NOT open the Desktop when running this test class""" + return + + +@pytest.fixture +def basic_doa(): + # Antenna array setup + freq = 10e9 # Hz + num_elements_x = 4 + num_elements_y = 4 + d = 0.015 + x = np.tile(np.arange(num_elements_x) * d, num_elements_y) + y = np.repeat(np.arange(num_elements_y) * d, num_elements_x) + return DirectionOfArrival(x, y, freq) + + +def test_initialization_error(): + x = np.array([0.0, 0.01]) + y = np.array([0.0]) + freq = 10e9 + with pytest.raises(ValueError): + DirectionOfArrival(x, y, freq) + + +def test_get_scanning_vectors(basic_doa): + azimuths = np.array([-45, 0, 45]) + vectors = basic_doa.get_scanning_vectors(azimuths) + assert vectors.shape == (basic_doa.elements, len(azimuths)) + assert np.iscomplexobj(vectors) + + +def test_bartlett(basic_doa): + azimuths = np.linspace(-90, 90, 181) + scanning_vectors = basic_doa.get_scanning_vectors(azimuths) + signal = np.random.randn(basic_doa.elements) + 1j * np.random.randn(basic_doa.elements) + data = np.array([signal]) + output = basic_doa.bartlett(data, scanning_vectors) + assert output.shape == (1, len(azimuths)) + assert np.iscomplexobj(output) + + +def test_capon(basic_doa): + azimuths = np.linspace(-90, 90, 181) + scanning_vectors = basic_doa.get_scanning_vectors(azimuths) + signal = np.random.randn(basic_doa.elements) + 1j * np.random.randn(basic_doa.elements) + data = np.array([signal]) + output = basic_doa.capon(data, scanning_vectors) + assert output.shape == (1, len(azimuths)) + assert np.isrealobj(output) + + +def test_music(basic_doa): + azimuths = np.linspace(-90, 90, 181) + scanning_vectors = basic_doa.get_scanning_vectors(azimuths) + signal = np.random.randn(basic_doa.elements) + 1j * np.random.randn(basic_doa.elements) + data = np.array([signal]) + output = basic_doa.music(data, scanning_vectors, signal_dimension=1) + assert output.shape == (1, len(azimuths)) + assert np.isrealobj(output) + + +def test_invalid_doa_method(basic_doa): + signal = np.random.randn(basic_doa.elements) + 1j * np.random.randn(basic_doa.elements) + with pytest.raises(ValueError): + basic_doa.plot_angle_of_arrival(signal, doa_method="InvalidMethod") + + +def test_plot_angle_of_arrival(basic_doa): + signal = np.random.randn(basic_doa.elements) + 1j * np.random.randn(basic_doa.elements) + plotter = basic_doa.plot_angle_of_arrival(signal, doa_method="Bartlett", show=False) + assert plotter is not None