Skip to content

Commit efe2c43

Browse files
FEAT: FRTM new methods and DOA new features (#6221)
Co-authored-by: pyansys-ci-bot <[email protected]>
1 parent 12fdca4 commit efe2c43

File tree

7 files changed

+1154
-75
lines changed

7 files changed

+1154
-75
lines changed

doc/changelog.d/6221.added.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Frtm new methods and doa new features

doc/styles/config/vocabularies/ANSYS/accept.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ airgap
88
(?i)Ansys
99
API
1010
autosave
11+
beamforming
1112
brd
1213
busbar
1314
busbars
Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright (C) 2021 - 2025 ANSYS, Inc. and/or its affiliates.
4+
# SPDX-License-Identifier: MIT
5+
#
6+
#
7+
# Permission is hereby granted, free of charge, to any person obtaining a copy
8+
# of this software and associated documentation files (the "Software"), to deal
9+
# in the Software without restriction, including without limitation the rights
10+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11+
# copies of the Software, and to permit persons to whom the Software is
12+
# furnished to do so, subject to the following conditions:
13+
#
14+
# The above copyright notice and this permission notice shall be included in all
15+
# copies or substantial portions of the Software.
16+
#
17+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23+
# SOFTWARE.
24+
25+
import sys
26+
27+
import numpy as np
28+
29+
from ansys.aedt.core.generic.constants import SpeedOfLight
30+
from ansys.aedt.core.generic.general_methods import conversion_function
31+
from ansys.aedt.core.generic.general_methods import pyaedt_function_handler
32+
from ansys.aedt.core.visualization.plot.matplotlib import ReportPlotter
33+
34+
current_python_version = sys.version_info[:2]
35+
if current_python_version < (3, 10): # pragma: no cover
36+
raise Exception("Python 3.10 or higher is required for direction of arrival (DoA) post-processing.")
37+
38+
39+
class DirectionOfArrival:
40+
"""
41+
Class for direction of arrival (DoA) estimation using 2D planar antenna arrays
42+
with coordinates in meters and user-defined frequency.
43+
"""
44+
45+
def __init__(self, x_position: np.array, y_position: np.array, frequency: float):
46+
"""
47+
Initialize with antenna element positions in meters and signal frequency in Hertz.
48+
49+
Parameters
50+
----------
51+
x_position : np.ndarray
52+
X coordinates of the antenna elements in meters.
53+
y_position : np.ndarray
54+
Y coordinates of the antenna elements in meters.
55+
frequency : float
56+
Signal frequency in Hertz.
57+
"""
58+
self.x = np.asarray(x_position)
59+
self.y = np.asarray(y_position)
60+
self.elements = len(self.x)
61+
self.frequency = frequency
62+
self.wavelength = SpeedOfLight / self.frequency
63+
self.k = 2 * np.pi / self.wavelength
64+
65+
if self.elements != len(self.y):
66+
raise ValueError("X and Y coordinate arrays must have the same length.")
67+
68+
@pyaedt_function_handler()
69+
def get_scanning_vectors(self, azimuth_angles: np.ndarray) -> np.ndarray:
70+
"""
71+
Generate scanning vectors for the given azimuth angles in degrees.
72+
73+
Parameters
74+
----------
75+
azimuth_angles : np.ndarray
76+
Incident azimuth angles in degrees.
77+
78+
Returns
79+
-------
80+
scanning_vectors : np.ndarray
81+
Scanning vectors.
82+
"""
83+
thetas_rad = np.deg2rad(azimuth_angles)
84+
P = len(thetas_rad)
85+
scanning_vectors = np.zeros((self.elements, P), dtype=complex)
86+
87+
for i in range(P):
88+
scanning_vectors[:, i] = np.exp(
89+
1j * self.k * (self.x * np.sin(thetas_rad[i]) + self.y * np.cos(thetas_rad[i]))
90+
)
91+
92+
return scanning_vectors
93+
94+
@pyaedt_function_handler()
95+
def bartlett(
96+
self, data: np.ndarray, scanning_vectors: np.ndarray, range_bins: int = None, cross_range_bins: int = None
97+
):
98+
"""
99+
Estimate the direction of arrival (DoA) using the Bartlett (classical beamforming) method.
100+
101+
Parameters
102+
----------
103+
data : np.ndarray
104+
Complex-valued array of shape (range_bins, elements), typically output from range FFT.
105+
Each row represents the antenna data for a specific range bin.
106+
scanning_vectors : np.ndarray
107+
Complex matrix of shape (elements, num_angles), where each column corresponds to
108+
a scanning vector for a different azimuth/elevation angle.
109+
range_bins : int, optional
110+
Number of range bins (rows of the output), defaults to the first dimension of `data`.
111+
cross_range_bins : int, optional
112+
Number of cross-range (angular) bins, defaults to the second dimension of `scanning_vectors`.
113+
114+
Returns
115+
-------
116+
np.ndarray
117+
2D complex-valued array of shape (range_bins, cross_range_bins), representing the
118+
power angular density (PAD) for each range bin and angle.
119+
"""
120+
121+
if range_bins is None:
122+
range_bins = data.shape[0]
123+
if cross_range_bins is None:
124+
cross_range_bins = scanning_vectors.shape[1]
125+
126+
scale_factor = scanning_vectors.shape[1] / cross_range_bins
127+
pad_output = np.zeros((range_bins, cross_range_bins), dtype=complex)
128+
129+
for n, range_bin_data in enumerate(data):
130+
range_bin_data = np.reshape(range_bin_data, (1, self.elements))
131+
correlation_matrix = np.dot(range_bin_data.T, range_bin_data.conj())
132+
133+
if correlation_matrix.shape[0] != correlation_matrix.shape[1]:
134+
raise ValueError("Correlation matrix is not square.")
135+
if correlation_matrix.shape[0] != scanning_vectors.shape[0]:
136+
raise ValueError("Dimension mismatch between correlation matrix and scanning vectors.")
137+
138+
pad = np.zeros(scanning_vectors.shape[1], dtype=complex)
139+
for i in range(scanning_vectors.shape[1]):
140+
steering_vector = scanning_vectors[:, i]
141+
pad[i] = steering_vector.conj().T @ correlation_matrix @ steering_vector
142+
143+
pad_output[n] = pad * scale_factor
144+
145+
return pad_output
146+
147+
def capon(
148+
self, data: np.ndarray, scanning_vectors: np.ndarray, range_bins: int = None, cross_range_bins: int = None
149+
) -> np.ndarray:
150+
"""
151+
Estimate the direction of arrival using the Capon (Minimum variance distortion less response)
152+
beamforming method.
153+
154+
Parameters
155+
----------
156+
data : np.ndarray
157+
Complex-valued array of shape (range_bins, elements), typically output from range FFT.
158+
Each row represents the antenna data for a specific range bin.
159+
scanning_vectors : np.ndarray
160+
Complex matrix of shape (elements, num_angles), where each column corresponds to
161+
a scanning vector for a different azimuth/elevation angle.
162+
range_bins : int, optional
163+
Number of range bins (rows of the output), defaults to the first dimension of `data`.
164+
cross_range_bins : int, optional
165+
Number of cross-range (angular) bins, defaults to the second dimension of `scanning_vectors`.
166+
167+
Returns
168+
-------
169+
np.ndarray
170+
2D real-valued array of shape (range_bins, cross_range_bins), representing the
171+
Capon spatial spectrum (inverse of interference power) for each range bin and angle.
172+
"""
173+
174+
if range_bins is None:
175+
range_bins = data.shape[0]
176+
if cross_range_bins is None:
177+
cross_range_bins = scanning_vectors.shape[1]
178+
179+
scale_factor = scanning_vectors.shape[1] / cross_range_bins
180+
spectrum_output = np.zeros((range_bins, cross_range_bins), dtype=float)
181+
182+
for n, range_bin_data in enumerate(data):
183+
range_bin_data = np.reshape(range_bin_data, (1, self.elements))
184+
R = range_bin_data.T @ range_bin_data.conj()
185+
186+
if R.shape[0] != R.shape[1]:
187+
raise ValueError("Correlation matrix is not square.")
188+
if R.shape[0] != scanning_vectors.shape[0]:
189+
raise ValueError("Dimension mismatch between correlation matrix and scanning vectors.")
190+
191+
try:
192+
R_inv = np.linalg.inv(R)
193+
except np.linalg.LinAlgError:
194+
raise ValueError("Correlation matrix is singular or ill-conditioned.")
195+
196+
for i in range(cross_range_bins):
197+
sv = scanning_vectors[:, i]
198+
denom = np.conj(sv).T @ R_inv @ sv
199+
spectrum_output[n, i] = scale_factor / np.real(denom)
200+
201+
return spectrum_output
202+
203+
@pyaedt_function_handler()
204+
def music(
205+
self,
206+
data: np.ndarray,
207+
scanning_vectors: np.ndarray,
208+
signal_dimension: int,
209+
range_bins: int = None,
210+
cross_range_bins: int = None,
211+
) -> np.ndarray:
212+
"""
213+
Estimate the direction of arrival (DoA) using the MUSIC method.
214+
215+
Parameters
216+
----------
217+
data : np.ndarray
218+
Complex-valued array of shape (range_bins, elements), typically output from range FFT.
219+
Each row represents the antenna data for a specific range bin.
220+
scanning_vectors : np.ndarray
221+
Matrix of shape (elements, num_angles), where each column is a steering vector for a test angle.
222+
signal_dimension : int
223+
Number of sources/signals (model order).
224+
range_bins : int, optional
225+
Number of range bins to process. Defaults to `data.shape[0]`.
226+
cross_range_bins : int, optional
227+
Number of angle bins (scan directions). Defaults to `scanning_vectors.shape[1]`.
228+
229+
Returns
230+
-------
231+
np.ndarray
232+
2D real-valued array of shape (range_bins, cross_range_bins),
233+
representing the MUSIC spectrum for each range bin and angle.
234+
"""
235+
if range_bins is None:
236+
range_bins = data.shape[0]
237+
if cross_range_bins is None:
238+
cross_range_bins = scanning_vectors.shape[1]
239+
240+
output = np.zeros((range_bins, cross_range_bins), dtype=float)
241+
242+
for n, snapshot in enumerate(data):
243+
snapshot = snapshot.reshape((1, self.elements))
244+
R = np.dot(snapshot.T, snapshot.conj())
245+
246+
if R.shape[0] != R.shape[1]:
247+
raise ValueError("Correlation matrix is not square.")
248+
if R.shape[0] != scanning_vectors.shape[0]:
249+
raise ValueError("Dimension mismatch between correlation matrix and scanning vectors.")
250+
251+
try:
252+
eigenvalues, eigenvectors = np.linalg.eigh(R)
253+
except np.linalg.LinAlgError:
254+
raise np.linalg.LinAlgError("Failed to compute eigendecomposition (singular matrix).")
255+
256+
M = R.shape[0]
257+
noise_dim = M - signal_dimension
258+
idx = np.argsort(eigenvalues)
259+
En = eigenvectors[:, idx[:noise_dim]] # Noise subspace
260+
261+
spectrum = np.zeros(cross_range_bins, dtype=float)
262+
for i in range(cross_range_bins):
263+
sv = scanning_vectors[:, i]
264+
denom = np.abs(sv.conj().T @ En @ En.conj().T @ sv)
265+
spectrum[i] = 0.0 if denom == 0 else 1.0 / denom
266+
267+
output[n] = spectrum
268+
269+
return output
270+
271+
@pyaedt_function_handler()
272+
def plot_angle_of_arrival(
273+
self,
274+
signal: np.ndarray,
275+
doa_method: str = None,
276+
field_of_view=None,
277+
quantity_format: str = None,
278+
title: str = "Angle of Arrival",
279+
output_file: str = None,
280+
show: bool = True,
281+
show_legend: bool = True,
282+
plot_size: tuple = (1920, 1440),
283+
figure=None,
284+
):
285+
"""Create angle of arrival plot.
286+
287+
Parameters
288+
----------
289+
signal : np.ndarray
290+
Frame number. The default is ``None``, in which case all frames are used.
291+
doa_method : str, optional
292+
Method used for direction of arrival estimation.
293+
Available options are: ``"Bartlett"``, ``"Capon"``, and ``"Music"``.
294+
The default is ``None``, in which case ``"Bartlett"`` is selected.
295+
field_of_view : np.ndarray, optional
296+
Azimuth angular span in degrees to plot. The default is from -90 to 90 dregress.
297+
quantity_format : str, optional
298+
Conversion data function. The default is ``None``.
299+
Available functions are: ``"abs"``, ``"ang"``, ``"dB10"``, ``"dB20"``, ``"deg"``, ``"imag"``, ``"norm"``,
300+
and ``"real"``.
301+
title : str, optional
302+
Title of the plot. The default is ``"Range profile"``.
303+
output_file : str or :class:`pathlib.Path`, optional
304+
Full path for the image file. The default is ``None``, in which case an image in not exported.
305+
show : bool, optional
306+
Whether to show the plot. The default is ``True``.
307+
If ``False``, the Matplotlib instance of the plot is shown.
308+
show_legend : bool, optional
309+
Whether to display the legend or not. The default is ``True``.
310+
plot_size : tuple, optional
311+
Image size in pixel (width, height).
312+
figure : :class:`matplotlib.pyplot.Figure`, optional
313+
An existing Matplotlib `Figure` to which the plot is added.
314+
If not provided, a new `Figure` and `Axes` objects are created.
315+
Default is ``None``.
316+
317+
Returns
318+
-------
319+
:class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
320+
PyAEDT matplotlib figure object.
321+
322+
Examples
323+
--------
324+
>>> from ansys.aedt.core.visualization.advanced.doa import DirectionOfArrival
325+
>>> import numpy as np
326+
>>> freq = 10e9
327+
>>> signal_angle = 30
328+
>>> num_elements_x = 4
329+
>>> num_elements_y = 4
330+
>>> d = 0.015
331+
>>> x = np.tile(np.arange(num_elements_x) * d, num_elements_y)
332+
>>> y = np.repeat(np.arange(num_elements_y) * d, num_elements_x)
333+
>>> k = 2 * np.pi * freq / 3e8
334+
>>> signal_vector = np.exp(
335+
... 1j * k * (x * np.sin(np.radians(signal_angle)) + y * np.cos(np.radians(signal_angle)))
336+
... )
337+
>>> signal_snapshot = signal_vector + 0.1 * (
338+
... np.random.randn(len(signal_vector)) + 1j * np.random.randn(len(signal_vector))
339+
... )
340+
>>> doa = DirectionOfArrival(x, y, freq)
341+
>>> doa.plot_angle_of_arrival(signal_snapshot)
342+
>>> doa.plot_angle_of_arrival(signal_snapshot, doa_method="MUSIC")
343+
"""
344+
345+
data = np.array([signal])
346+
347+
if field_of_view is None:
348+
field_of_view = np.linspace(-90, 90, 181)
349+
350+
if doa_method is None:
351+
doa_method = "Bartlett"
352+
353+
scanning_vectors = self.get_scanning_vectors(field_of_view)
354+
355+
if doa_method.lower() == "bartlett":
356+
output = self.bartlett(data, scanning_vectors)
357+
elif doa_method.lower() == "capon":
358+
output = self.capon(data, scanning_vectors)
359+
elif doa_method.lower() == "music":
360+
output = self.music(data, scanning_vectors, 1)
361+
else:
362+
raise ValueError(f"Unknown {doa_method} method.")
363+
364+
if quantity_format is None:
365+
quantity_format = "dB20"
366+
367+
output = conversion_function(output, quantity_format)
368+
369+
new = ReportPlotter()
370+
new.show_legend = show_legend
371+
new.title = title
372+
new.size = plot_size
373+
374+
x = field_of_view
375+
y = output.T
376+
377+
legend = f"DoA {doa_method}"
378+
curve = [x.tolist(), y.tolist(), legend]
379+
380+
# Single plot
381+
props = {"x_label": "Azimuth (°)", "y_label": "Power"}
382+
name = curve[2]
383+
new.add_trace(curve[:2], 0, props, name)
384+
new.x_margin_factor = 0.0
385+
new.y_margin_factor = 0.2
386+
_ = new.plot_2d(None, output_file, show, figure=figure)
387+
return new

0 commit comments

Comments
 (0)