# -*- coding: utf-8 -*-
#
# Copyright (C) 2024 - 2025 ANSYS, Inc. and/or its affiliates.
# SPDX-License-Identifier: Apache-2.0
#
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from enum import Enum
import json
from pathlib import Path
import sys
import warnings
from ansys.aedt.core.aedt_logger import pyaedt_logger as logger
from ansys.aedt.core.generic.constants import AEDT_UNITS
from ansys.aedt.core.generic.constants import SpeedOfLight
from ansys.aedt.core.generic.constants import unit_converter
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.generic.numbers_utils import decompose_variable_value
from ansys.aedt.core.internal.checks import ERROR_GRAPHICS_REQUIRED
from ansys.aedt.core.internal.checks import check_graphics_available
from ansys.aedt.core.internal.checks import graphics_required
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 later is required for radar postprocessing.")
try:
from scipy.interpolate import RegularGridInterpolator
except ImportError: # pragma: no cover
warnings.warn(
"The SciPy module is required to use the 'rcs_visualization.py' module.\nInstall with \n\npip install scipy"
)
try:
import numpy as np
except ImportError: # pragma: no cover
warnings.warn(
"The NumPy module is required to use the 'rcs_visualization.py' module.\nInstall with \n\npip install numpy"
)
np = None
# Check that graphics are available
try:
check_graphics_available()
import pyvista as pv
from ansys.tools.visualization_interface import MeshObjectPlot
from ansys.tools.visualization_interface import Plotter
from ansys.tools.visualization_interface.backends.pyvista import PyVistaBackend
except ImportError: # pragma: no cover
warnings.warn(ERROR_GRAPHICS_REQUIRED)
try:
import pandas as pd
except ImportError: # pragma: no cover
warnings.warn(
"The Pandas module is required to use the `rcs_visualization.py.` module. \nInstall with \n\npip install pandas"
)
pd = None
[docs]
class MonostaticRCSData(object):
"""Provides monostatic RCS data.
This class reads the monostatic RCS metadata in a JSON file
generated by the PyAEDT ``MonostaticRCSExporter`` class
and returns the Python interface to plot and analyze
the RCS data.
Parameters
----------
input_file : str
Metadata information in a JSON file.
Examples
--------
>>> from ansys.aedt.core import Hfss
>>> from ansys.aedt.toolkits.radar_explorer.rcs_visualization import MonostaticRCSData
>>> app = Hfss(version="2025.1", design="Antenna")
>>> data = app.get_rcs_data()
>>> metadata_file = data.metadata_file
>>> app.release_desktop()
>>> rcs_data = MonostaticRCSData(input_file=metadata_file)
"""
def __init__(self, input_file):
input_file = Path(input_file)
# Public
self.output_dir = input_file.parent
if not input_file.is_file():
raise FileNotFoundError("JSON file does not exist.")
# Private
self.__logger = logger
self.__input_file = input_file
self.__raw_data = {}
self.__frequency = None
self.__name = None
self.__solution = None
self.__incident_wave_theta = None
self.__incident_wave_phi = None
self.__available_incident_wave_theta = None
self.__available_incident_wave_phi = None
self.__frequencies = []
with input_file.open("r") as file:
self.__metadata = json.load(file)
self.__frequency_units = self.__metadata["frequency_units"]
self.__monostatic_file = None
if self.__metadata["monostatic_file"]:
self.__monostatic_file = self.output_dir / self.__metadata["monostatic_file"]
self.__data_conversion_function = "dB20"
self.__window = "Flat"
self.__window_size = 1024
self.__aspect_range = "Horizontal"
self.__upsample_range = 512
self.__upsample_azimuth = 64
self.__upsample_elevation = 64
self.__interpolation = "linear"
self.__extrapolate = True
self.__gridsize = "Middle"
if self.__monostatic_file and not self.__monostatic_file.is_file():
raise Exception("Monostatic file is invalid.")
self.rcs_column_names = ["data"]
# Load farfield data
if self.__monostatic_file:
is_rcs_loaded = self.__init_rcs()
else:
is_rcs_loaded = True
if not is_rcs_loaded: # pragma: no cover
raise RuntimeError("RCS information cannot be loaded.")
if self.__monostatic_file:
# Update active frequency if passed in the initialization
self.frequency = self.frequencies[0]
@property
def raw_data(self):
"""Antenna data."""
return self.__raw_data
@property
def metadata(self):
"""Antenna metadata."""
return self.__metadata
@property
def name(self):
"""Data name."""
if isinstance(self.raw_data, pd.DataFrame):
self.__name = self.raw_data.columns[0]
return self.__name
@property
def solution(self):
"""Data solution name."""
self.__solution = self.__metadata["solution"]
return self.__solution
@property
def input_file(self):
"""Input file."""
return self.__input_file
@property
def frequency_units(self):
"""Frequency units."""
return self.__frequency_units
@property
def frequencies(self):
"""Available frequencies."""
if isinstance(self.raw_data, pd.DataFrame) and "Freq" in self.raw_data.index.names:
frequencies = np.unique(np.array(self.raw_data.index.get_level_values("Freq")))
self.__frequencies = frequencies.tolist()
return self.__frequencies
@property
def available_incident_wave_theta(self):
"""Available incident wave theta."""
if isinstance(self.raw_data, pd.DataFrame) and "IWaveTheta" in self.raw_data.index.names:
self.__available_incident_wave_theta = np.unique(
np.array(self.raw_data.index.get_level_values("IWaveTheta"))
)
return self.__available_incident_wave_theta
@property
def incident_wave_theta(self):
"""Active incident wave theta."""
if isinstance(self.raw_data, pd.DataFrame) and self.__incident_wave_theta is None:
self.__incident_wave_theta = self.available_incident_wave_theta[0]
return self.__incident_wave_theta
@incident_wave_theta.setter
def incident_wave_theta(self, val):
"""Active incident wave theta."""
if val in self.available_incident_wave_theta:
self.__incident_wave_theta = val
else:
self.__logger.error("Value is not available.")
@property
def available_incident_wave_phi(self):
"""Available incident wave phi."""
if isinstance(self.raw_data, pd.DataFrame) and "IWavePhi" in self.raw_data.index.names:
self.__available_incident_wave_phi = np.unique(np.array(self.raw_data.index.get_level_values("IWavePhi")))
return self.__available_incident_wave_phi
@property
def incident_wave_phi(self):
"""Active incident wave phi."""
if isinstance(self.raw_data, pd.DataFrame) and self.__incident_wave_phi is None:
self.__incident_wave_phi = self.available_incident_wave_phi[0]
return self.__incident_wave_phi
@incident_wave_phi.setter
def incident_wave_phi(self, val):
"""Active incident wave phi."""
if val in self.available_incident_wave_phi:
self.__incident_wave_phi = val
else:
self.__logger.error("Value is not available.")
@property
def frequency(self):
"""Active frequency."""
return self.__frequency
@frequency.setter
def frequency(self, val):
if isinstance(val, str):
frequency, units = decompose_variable_value(val)
unit_converter(frequency, "Freq", units, self.frequency_units)
val = frequency
if val in self.frequencies:
self.__frequency = val
# self.__freq_index = self.frequencies.index(val)
else:
self.__logger.error("Frequency not available.")
@property
def data_conversion_function(self):
"""RCS data conversion function.
The available functions are:
- ``"dB10"``: Converts the data to decibels using the base 10 logarithm.
- ``"dB20"``: Converts the data to decibels using the base 20 logarithm.
- ``"abs"``: Computes the absolute value of the data.
- ``"real"``: Computes the real part of the data.
- ``"imag"``: Computes the imaginary part of the data.
- ``"norm"``: Normalizes the data to have values between 0 and 1.
- ``"ang"``: Computes the phase angle of the data in radians.
- ``"ang_deg"``: Computes the phase angle of the data in degrees.
"""
return self.__data_conversion_function
@data_conversion_function.setter
def data_conversion_function(self, val):
available_functions = ["dB10", "dB20", "abs", "real", "imag", "norm", "ang", "ang_deg", None]
if val in available_functions:
self.__data_conversion_function = val
@property
def interpolation(self):
"""Interpolation method."""
return self.__interpolation
@interpolation.setter
def interpolation(self, val):
if val in RegularGridInterpolator._ALL_METHODS:
self.__interpolation = val
else:
self.__logger.error("Interpolation method not available.")
@property
def extrapolate(self):
"""Extrapolation flag."""
return self.__extrapolate
@extrapolate.setter
def extrapolate(self, val):
if isinstance(val, bool):
self.__extrapolate = val
else:
self.__logger.error("Extrapolation flag must be a boolean value.")
@property
def gridsize(self):
"""Grid size for ISAR."""
return self.__gridsize
@gridsize.setter
def gridsize(self, val):
available_gridsizes = ["Inside", "Outside", "Middle"]
if val in available_gridsizes:
self.__gridsize = val
else:
self.__logger.error("Value for `gridsize` is invalid. The value must be 'Inside', 'Outside', or 'Middle'.")
@property
def window(self):
"""Window function.
The available functions are ``"Flat"``, ``"Hamming"``, and ``"Hann"``.
"""
return self.__window
@window.setter
def window(self, val):
available_functions = ["Flat", "Hamming", "Hann"]
if val in available_functions:
self.__window = val
else:
self.__logger.error("Value for `window` is invalid. The value must be 'Flat', 'Hamming', or 'Hann'.")
@property
def window_size(self):
"""Window size."""
return self.__window_size
@window_size.setter
def window_size(self, val):
self.__window_size = val
@property
def aspect_range(self):
"""Aspect range for ISAR."""
return self.__aspect_range
@aspect_range.setter
def aspect_range(self, val):
self.__aspect_range = val
@property
def upsample_range(self):
"""Upsample range for ISAR."""
return self.__upsample_range
@upsample_range.setter
def upsample_range(self, val):
self.__upsample_range = val
@property
def upsample_azimuth(self):
"""Upsample azimuth for ISAR."""
return self.__upsample_azimuth
@upsample_azimuth.setter
def upsample_azimuth(self, val):
self.__upsample_azimuth = val
@property
def upsample_elevation(self):
"""Upsample elevation for ISAR."""
return self.__upsample_elevation
@upsample_elevation.setter
def upsample_elevation(self, val):
self.__upsample_elevation = val
@property
def rcs(self):
"""RCS data for active frequency, theta, and phi."""
rcs_value = None
if isinstance(self.raw_data, pd.DataFrame):
data = self.rcs_active_theta_phi
data = data[data["Freq"] == self.frequency]
data = data.drop(columns=["Freq"])
value = data.values
rcs_value = value[0][0]
return rcs_value
@property
def rcs_active_theta_phi(self):
"""RCS data for active theta and phi."""
rcs_value = None
if isinstance(self.rcs_active_theta, pd.DataFrame):
data = self.rcs_active_theta
data = data[data["IWavePhi"] == self.incident_wave_phi]
rcs_value = data.drop(columns=["IWavePhi"])
return rcs_value
@property
def rcs_active_frequency(self):
"""RCS data for active frequency."""
value = None
if isinstance(self.raw_data, pd.DataFrame):
data = self.raw_data.xs(key=self.frequency, level="Freq")
data_converted = conversion_function(data[self.name], self.data_conversion_function)
df = data_converted.reset_index()
df.columns = ["IWavePhi", "IWaveTheta", "Data"]
value = df
return value
@property
def rcs_active_theta(self):
"""RCS data for active incident wave theta."""
value = None
if isinstance(self.raw_data, pd.DataFrame):
data = self.raw_data.xs(key=self.incident_wave_theta, level="IWaveTheta")
df = data.reset_index()
df.columns = ["Freq", "IWavePhi", "Data"]
value = df
return value
@property
def rcs_active_phi(self):
"""RCS data for active incident wave phi."""
value = None
if isinstance(self.raw_data, pd.DataFrame):
data = self.raw_data.xs(key=self.incident_wave_phi, level="IWavePhi")
df = data.reset_index()
df.columns = ["Freq", "IWaveTheta", "Data"]
value = df
return value
@property
def range_profile(self):
"""Range profile."""
value = None
if isinstance(self.raw_data, pd.DataFrame):
data = self.rcs_active_theta_phi["Data"]
# Take needed properties
size = self.window_size
nfreq = len(self.frequencies)
# Compute window
win_range, _ = self.window_function(self.window, nfreq)
windowed_data = data * win_range
# Perform FFT
sf_upsample = self.window_size / nfreq
windowed_data = np.fft.fftshift(sf_upsample * np.fft.ifft(windowed_data.to_numpy(), n=size))
data_converted = conversion_function(windowed_data, self.data_conversion_function)
df = unit_converter((self.frequencies[1] - self.frequencies[0]), "Freq", self.frequency_units, "Hz")
pd_t = 1.0 / df
dt = pd_t / size
range_norm = dt * np.linspace(start=-0.5 * size, stop=0.5 * size - 1, num=size) / 2 * SpeedOfLight
index_names = ["Range", "Data"]
df = pd.DataFrame(columns=index_names)
df["Range"] = range_norm
df["Data"] = data_converted
value = df
return value
@property
def waterfall(self):
"""Waterfall."""
waterfall_df = None
if isinstance(self.raw_data, pd.DataFrame):
waterfall_data = []
if self.aspect_range == "Horizontal":
original_phi = self.incident_wave_phi
for phi in self.available_incident_wave_phi:
self.incident_wave_phi = phi
range_profile = self.range_profile
new_data = {
"Range": range_profile["Range"],
"Data": range_profile["Data"],
"IWavePhi": np.full(range_profile["Range"].size, phi),
}
waterfall_data.append(pd.DataFrame(new_data))
self.incident_wave_phi = original_phi
else:
original_theta = self.incident_wave_theta
for theta in self.available_incident_wave_theta:
self.incident_wave_theta = theta
range_profile = self.range_profile
new_data = {
"Range": range_profile["Range"],
"Data": range_profile["Data"],
"IWaveTheta": np.full(range_profile["Range"].size, theta),
}
waterfall_data.append(pd.DataFrame(new_data))
self.incident_wave_theta = original_theta
waterfall_df = pd.concat(waterfall_data, ignore_index=True)
return waterfall_df
[docs]
@staticmethod
def phase_shift(data, slice_idx, num_after_fft, num_before_fft):
"""Apply phase shift to the data if the output has an even number of points in the ``slice_idx`` direction."""
if num_after_fft % 2 == 0:
d_arg = np.pi / num_after_fft
tmp = np.floor(-0.5 * num_before_fft) * d_arg
rng_shift_base = complex(np.cos(tmp), np.sin(tmp))
rng_shift_delta = complex(np.cos(d_arg), np.sin(d_arg))
for i in range(num_before_fft):
idx = [slice(None)] * data.ndim
idx[slice_idx] = i # this will pick [:, i] or [i, :] based on slice_idx
data[tuple(idx)] *= rng_shift_base
rng_shift_base *= rng_shift_delta
return data
@property
def isar_2d(self):
"""ISAR 2D."""
df = None
if isinstance(self.raw_data, pd.DataFrame):
# output size
ndrng = self.upsample_range
# get the input data
freqs = np.array(unit_converter(self.frequencies, "Freq", self.frequency_units, "Hz"))
nfreq = len(freqs)
freqs = freqs
avail_phi = np.array(self.available_incident_wave_phi)
avail_theta = np.array(self.available_incident_wave_theta)
# We are trying to create a 2D ISAR CUT potentially based off 3D ISAR data. When we choose
# a cut other than the main cuts, either for phi = 0 or theta = 90, we need to
# interpolate the data to the desired cut, which lies on a great arc. Given that potentially
# we'd have to extrapolate data over a large portion of the cut, we choose to disable
# extrapolation by default, and just zero pad, except if the data lies explicitly on one
# of the two main cuts. Because of numerical precision, even on the main cuts the resulting
# interpolants won't line up precisely with the original data, but we can forgive that for the sake of
# result accuracy and allow extrapolation.
if self.aspect_range == "Horizontal":
# Great arc (equatorial) cut, rotated by theta0 about y-axis
# We choose an arc with the same length and spacing as the original data, to preserve the
# original data's resolution and image extents.
el0 = np.deg2rad(90.0 - self.incident_wave_theta)
nangles = len(avail_phi)
arc_angles = np.deg2rad(avail_phi)
# Start with equatorial great circle (theta=90, phi=arc_angles)
x0 = np.cos(arc_angles)
y0 = np.sin(arc_angles)
# Rotate by el0 about y-axis
x = x0 * np.cos(el0)
y = y0
z = x0 * np.sin(el0)
# Convert to (theta, phi)
thetas = np.arccos(np.clip(z, -1.0, 1.0))
phis = np.arctan2(y, x)
theta_deg = np.rad2deg(thetas)
phi_deg = np.rad2deg(phis)
interp = RegularGridInterpolator(
(freqs, avail_phi, avail_theta),
self.raw_data.to_numpy().reshape((nfreq, len(avail_phi), len(avail_theta))),
method=self.interpolation,
bounds_error=False,
fill_value=None if self.incident_wave_theta == 90.0 else 0.0,
)
azel_samples = -arc_angles # convert from phi to azimuth
nxrng = nangles
else:
# Great arc (equatorial) cut logic for elevation
# We choose an arc with the same length and spacing as the original data, to preserve the
# original data's resolution and image extents.
phi0 = np.deg2rad(self.incident_wave_phi)
nangles = len(avail_theta)
arc_angles = np.deg2rad(avail_theta)
# Start with equatorial great circle (phi=0, theta=arc_angles)
x0 = np.sin(arc_angles)
z0 = np.cos(arc_angles)
# Rotate by phi0 about z-axis
x = x0 * np.cos(phi0)
y = x0 * np.sin(phi0)
z = z0
# Convert to (theta, phi)
thetas = np.arccos(np.clip(z, -1.0, 1.0))
phis = np.arctan2(y, x)
theta_deg = np.rad2deg(thetas)
phi_deg = np.rad2deg(phis)
interp = RegularGridInterpolator(
(freqs, avail_phi, avail_theta),
self.raw_data.to_numpy().reshape((nfreq, len(avail_phi), len(avail_theta))),
method=self.interpolation,
bounds_error=False,
fill_value=None if self.incident_wave_phi == 0.0 else 0.0,
)
azel_samples = np.pi / 2 - arc_angles # convert from theta to elevation
nxrng = nangles
# Interpolate for all frequencies over the points in the line el_deg, az_deg to obtain
# data along the desired cut.
data = np.empty((nfreq, nangles), dtype=np.complex128)
for i, freq in enumerate(freqs):
freq_points = np.column_stack([np.full_like(theta_deg, freq), phi_deg, theta_deg])
data[i, :] = interp(freq_points)
# center the cut samples, just in case
azel_samples = np.unwrap(azel_samples)
azel_samples -= np.mean(azel_samples)
fxmin = np.min(freqs)
fxmax = np.max(freqs)
if self.gridsize == "Inside":
f_ref = freqs[0]
elif self.gridsize == "Outside":
f_ref = freqs[-1]
else: # self.gridsize == "Middle" which matches the extents
f_ref = freqs[len(freqs) // 2]
fymax = np.sin(np.max(azel_samples)) * f_ref
fymin = np.sin(np.min(azel_samples)) * f_ref
fx = np.linspace(fxmin, fxmax, nfreq) # desired downrange frequencies
fy = np.linspace(fymin, fymax, nangles) # desired crossrange frequencies
f_x, f_y = np.meshgrid(fx, fy, indexing="ij")
# convert to equivalent freq and azimuthinterpolation points,
# so we can use the regular interpolator directly from freqs, az
fi = np.sqrt(f_x**2 + f_y**2)
azi = np.arctan2(f_y, f_x)
interp = RegularGridInterpolator(
(freqs, azel_samples),
data,
method=self.interpolation,
bounds_error=False,
fill_value=None if self.extrapolate else 0.0,
)
rdata = interp((fi, azi))
# Zero padding
if ndrng < nfreq:
# Warning('nx should be at least as large as the length of f -- increasing nx')
self.__logger.warning("nx should be at least as large as the number of frequencies.")
ndrng = nfreq
if nxrng < nangles: # pragma: no cover
# warning('ny should be at least as large as the length of az -- increasing ny');
self.__logger.warning("ny should be at least as large as the number of azimuth angles.")
nxrng = nangles
# Compute the image plane downrange and cross-range distance vectors (in
# meters)
dfx = fx[1] - fx[0] # difference in x-frequencies
dfy = fy[1] - fy[0] # difference in y-frequencies
dx = SpeedOfLight / (2 * dfx) / ndrng
dy = SpeedOfLight / (2 * dfy) / nxrng
x = np.linspace(start=0, stop=ndrng * dx, num=ndrng) # ndrng
y = np.linspace(start=0, stop=nxrng * dy, num=nxrng) # nxrng
# We want the physical extents of the image to be centered at the global origin, because
# that's how we draw the extents of the 2D ISAR domain.
# The center of the first pixel in the second half of each domain is centered at zero
# if the domain has odd length, but it is at dx/2 otherwise.
fft_domain_sizes = ((ndrng, nfreq), (nxrng, nangles))
for islide, fft_domain_size in enumerate(fft_domain_sizes):
rdata = MonostaticRCSData.phase_shift(rdata, islide, *fft_domain_size)
winx, winx_sum = self.window_function(self.window, nfreq)
winy, winy_sum = self.window_function(self.window, nangles)
winx = winx.reshape(-1, 1)
winy = winy.reshape(1, -1)
iq = np.zeros((ndrng, nxrng), dtype=np.complex128)
xshift = (ndrng - nfreq) // 2
yshift = (nxrng - nangles) // 2
iq[xshift : xshift + nfreq, yshift : yshift + nangles] = np.multiply(rdata, winx * winy)
# Normalize so that unit amplitude scatterers have about unit amplitude in
# the image (normalized for the windows). The "about" comes because of the
# truncation of the polar shape into a rectangular shape.
#
iq = np.fft.fftshift(iq) * ndrng * nxrng / winx_sum / winy_sum
isar_image = np.fft.fftshift(np.fft.ifft2(iq))
# Nx x Ny
isar_image = conversion_function(isar_image, self.data_conversion_function)
# isar_image = isar_image.transpose()
# isar_image = isar_image[::-1, :] # this used to be flipped, but it matched range/cross range defs now
# bring the center of the PHYSICAL image to 0, which means the first pixel on the
# second half is not at 0 for even length domains
range_values = x - 0.5 * (x[-1] - x[0])
cross_range_values = y - 0.5 * (y[-1] - y[0])
rr, xr = np.meshgrid(range_values, cross_range_values)
rr_flat = rr.ravel()
xr_flat = xr.ravel()
isar_image_flat = isar_image.ravel()
index_names = ["Down-range", "Cross-range", "Data"]
df = pd.DataFrame(columns=index_names)
df["Down-range"] = rr_flat
df["Cross-range"] = xr_flat
df["Data"] = isar_image_flat
return df
@property
def isar_3d(self):
"""ISAR 3D."""
df = None
if isinstance(self.raw_data, pd.DataFrame):
# get the input data
freqs = np.array(unit_converter(self.frequencies, "Freq", self.frequency_units, "Hz"))
nfreq = len(freqs)
az = np.unwrap(np.radians(-self.available_incident_wave_phi))
az -= np.mean(az)
nphi = len(az)
el = np.unwrap(np.radians(90.0 - self.available_incident_wave_theta))
el -= np.mean(el)
ntheta = len(el)
data = self.__raw_data
# output size
ndrng = self.upsample_range
nxrng1 = self.upsample_azimuth
nxrng2 = self.upsample_elevation
fxmin = np.min(freqs)
fxmax = np.max(freqs)
if self.gridsize == "Inside":
f_ref = freqs[0]
elif self.gridsize == "Outside":
f_ref = freqs[-1]
else: # self.gridsize == "Middle" which matches the extents
f_ref = freqs[len(freqs) // 2]
fymin = np.sin(np.min(az)) * f_ref
fymax = np.sin(np.max(az)) * f_ref
fzmin = np.sin(np.min(el)) * f_ref
fzmax = np.sin(np.max(el)) * f_ref
fx = np.linspace(fxmin, fxmax, nfreq) # desired downrange frequencies
fy = np.linspace(fymin, fymax, nphi) # desired crossrange frequencies
fz = np.linspace(fzmin, fzmax, ntheta) # desired crossrange frequencies
f_x, f_y, f_z = np.meshgrid(fx, fy, fz, indexing="ij")
# convert to equivalent freq, azimuth, and elevation interpolation points,
# so we can use the regular interpolator directly from freqs, az and el
fi = np.sqrt(f_x**2 + f_y**2 + f_z**2)
azi = np.arctan2(f_y, f_x)
with np.errstate(invalid="ignore"):
eli = np.arcsin(np.where(fi != 0, -f_z / fi, 0.0))
interp = RegularGridInterpolator(
(freqs, az, el),
data.to_numpy().reshape((nfreq, nphi, ntheta)),
method=self.interpolation,
bounds_error=False,
fill_value=None if self.extrapolate else 0.0,
)
rdata = interp((fi, azi, eli))
if ndrng < nfreq:
# Warning('nx should be at least as large as the length of f -- increasing nx')
self.__logger.warning("nx should be at least as large as the number of frequencies.")
ndrng = nfreq
if nxrng1 < nphi:
# warning('ny should be at least as large as the length of az -- increasing ny');
self.__logger.warning("ny should be at least as large as the number of azimuth angles.")
nxrng1 = nphi
if nxrng2 < ntheta:
# warning('nz should be at least as large as the length of el -- increasing nz');
self.__logger.warning("nz should be at least as large as the number of elevation angles.")
nxrng2 = ntheta
# We want the physical extents of the image to be centered at the global origin, because
# that's how we draw the extents of the 3D ISAR domain.
# The center of the first pixel in the second half of each domain is centered at zero
# if the domain has odd length, but it is at dx/2 otherwise.
fft_domain_sizes = ((ndrng, nfreq), (nxrng1, nphi), (nxrng2, ntheta))
for islide, fft_domain_size in enumerate(fft_domain_sizes):
rdata = MonostaticRCSData.phase_shift(rdata, islide, *fft_domain_size)
#
# add windowing
#
winx, winx_sum = self.window_function(self.window, nfreq)
winy, winy_sum = self.window_function(self.window, nphi)
winz, winz_sum = self.window_function(self.window, ntheta)
winx = winx.reshape(-1, 1, 1)
winy = winy.reshape(1, -1, 1)
winz = winz.reshape(1, 1, -1)
iq = np.zeros((ndrng, nxrng1, nxrng2), dtype=np.complex128)
xshift = (ndrng - nfreq) // 2
yshift = (nxrng1 - nphi) // 2
zshift = (nxrng2 - ntheta) // 2
window = winx * winy * winz
iq[xshift : xshift + nfreq, yshift : yshift + nphi, zshift : zshift + ntheta] = np.multiply(rdata, window)
#
# normalize so that unit amplitude scatterers have about unit amplitude in
# the image (normalized for the windows). The "about" comes because of the
# truncation of the polar shape into a rectangular shape.
#
iq = np.fft.fftshift(iq) * ndrng * nxrng1 * nxrng2 / winx_sum / winy_sum / winz_sum
isar_image = np.fft.fftshift(np.fft.ifftn(iq)) # Nx x Ny x Nz
isar_image = conversion_function(isar_image, self.data_conversion_function)
#
# compute the image plane downrange and crossrange distance vectors (in
# meters)
#
dfx = fx[1] - fx[0] # difference in x-frequencies
dfy = fy[1] - fy[0] # difference in y-frequencies
dfz = fz[1] - fz[0] # difference in z-frequencies
dx = SpeedOfLight / (2 * dfx) / ndrng
dy = SpeedOfLight / (2 * dfy) / nxrng1
dz = SpeedOfLight / (2 * dfz) / nxrng2
x = np.linspace(start=0, stop=ndrng * dx, num=ndrng)
y = np.linspace(start=0, stop=nxrng1 * dy, num=nxrng1)
z = np.linspace(start=0, stop=nxrng2 * dz, num=nxrng2)
#
# bring the center of the PHYSICAL image to 0, which means the first pixel on the
# second half is not at 0 for even length domains
range_values = x - 0.5 * (x[-1] - x[0])
cross_range1_values = y - 0.5 * (y[-1] - y[0])
cross_range2_values = z - 0.5 * (z[-1] - z[0])
rr, xr1, xr2 = np.meshgrid(range_values, cross_range1_values, cross_range2_values)
rr_flat = rr.ravel()
xr1_flat = xr1.ravel()
xr2_flat = xr2.ravel()
isar_image_flat = isar_image.ravel()
index_names = ["Down-range", "Cross-range-az", "Cross-range-el", "Data"]
df = pd.DataFrame(columns=index_names)
df["Down-range"] = rr_flat
df["Cross-range-az"] = xr1_flat
df["Cross-range-el"] = xr2_flat
df["Data"] = isar_image_flat
return df
[docs]
@staticmethod
def window_function(window="Flat", size=512):
"""Apply a window function.
Parameters
----------
window : str, default: ``"Flat"``
Unnormalized window function. Options are ``"Flat"``, ``"Hamming"``, and ``"Hann"``.
size : int, default: ``512``
Window size.
Returns
-------
tuple
Data windowed and data sum.
"""
if window == "Hann":
win = np.hanning(size)
elif window == "Hamming":
win = np.hamming(size)
else:
win = np.ones(size)
win_sum = np.sum(win)
return win, win_sum
@pyaedt_function_handler()
def __init_rcs(self):
"""Load monostatic radar cross-section data.
Returns
-------
bool
``True`` when successful, ``False`` when failed.
"""
try:
self.__raw_data = pd.read_hdf(self.__monostatic_file, key="df", mode="r")
except ImportError as e: # pragma: no cover
self.__logger.error(f"Failed to load monostatic RCS data: {e}")
return False
return True
[docs]
class MonostaticRCSPlotter(object):
"""Provides monostatic RCS plot functionalities.
Parameters
----------
rcs_data : :class:`ansys.aedt.toolkits.radar_explorer.rcs_visualization`, default: ``None``
Monostatic RCS data object.
Examples
--------
>>> from ansys.aedt.core import Hfss
>>> from ansys.aedt.toolkits.radar_explorer.rcs_visualization import MonostaticRCSData
>>> from ansys.aedt.toolkits.radar_explorer.rcs_visualization import MonostaticRCSPlotter
>>> app = Hfss(version="2025.1", design="Antenna")
>>> data = app.get_rcs_data()
>>> metadata_file = data.metadata_file
>>> app.release_desktop()
>>> rcs_data = MonostaticRCSData(input_file=metadata_file)
>>> rcs_plotter = MonostaticRCSPlotter(rcs_data)
"""
def __init__(self, rcs_data: MonostaticRCSData | None = None):
# Private
self.__rcs_data = rcs_data
self.__logger = logger
self.__model_units = "meter"
# Scene properties
self.show_geometry = True
self.__all_scene_actors = {"model": {}, "annotations": {}, "results": {}}
self.__x_max, self.__x_min, self.__y_max, self.__y_min, self.__z_max, self.__z_min = 1, -1, 1, -1, 1, -1
self.__model_info = None
self.__num_contours = 10
# Get geometries
if self.__rcs_data and "model_info" in self.rcs_data.metadata.keys():
self.__model_info = self.rcs_data.metadata["model_info"]
obj_meshes = self.__get_geometry()
self.__all_scene_actors["model"] = obj_meshes
# Get model extent
self.__get_model_extent()
@property
def rcs_data(self):
"""RCS data object."""
return self.__rcs_data
@property
def model_info(self):
"""Geometry information."""
return self.__model_info
@property
def model_units(self):
"""Model units."""
return self.__model_units
@property
def all_scene_actors(self):
"""All scene actors."""
return self.__all_scene_actors
@property
def extents(self):
"""Geometry extents."""
return [self.__x_min, self.__x_max, self.__y_min, self.__y_max, self.__z_min, self.__z_max]
@property
def center(self):
"""Geometry center."""
x_mid = (self.__x_max + self.__x_min) / 2
z_mid = (self.__z_max + self.__z_min) / 2
y_mid = (self.__y_max + self.__y_min) / 2
return np.array([x_mid, y_mid, z_mid])
@property
def radius(self):
"""Geometry radius."""
return max(
[abs(a) for a in (self.__x_min, self.__x_max, self.__y_min, self.__y_max, self.__z_min, self.__z_max)]
)
@property
def num_contours(self) -> int:
"""Number of contours."""
return self.__num_contours
@num_contours.setter
def num_contours(self, value):
self.__num_contours = value
[docs]
@pyaedt_function_handler()
def plot_rcs(
self,
primary_sweep="IWavePhi",
secondary_sweep="IWaveTheta",
secondary_sweep_value=None,
title="Monostatic RCS",
output_file=None,
show=True,
is_polar=False,
show_legend=True,
size=(1920, 1440),
):
"""Create a 2D plot of the monostatic RCS.
Parameters
----------
primary_sweep : str, default: ``"IWavePhi"``
X-axis variable. Options are ``"Freq"``, ``"IWavePhi"`` and ``"IWaveTheta"``.
secondary_sweep : str, default: ``"IWavePhi"``
Y-axis variable. Options are ``"Freq"``, ``"IWavePhi"`` and ``"IWaveTheta"``.
secondary_sweep_value : float, list, string, default: ``0``
List of cuts on the secondary sweep to plot. Options are
`"all"`, a single value float, or a list of float values.
title : str, default: ``"RectangularPlot"``
Plot title.
output_file : str, default: ``None``
Full path for the image file. The default is ``None``, in which case an image in not exported.
show : bool, default: ``True``
Whether to show the plot. If ``False``, the Matplotlib instance of the plot is shown.
is_polar : bool, default: ``True``
Whether this plot is a polar plot.
show_legend : bool, default: ``True``
Whether to display the legend.
size : tuple, default: (1920, 1440)
Image size in pixel (width, height).
Returns
-------
:class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
PyAEDT Matplotlib figure object.
"""
curves = []
all_secondary_sweep_value = secondary_sweep_value
if primary_sweep.casefold() == "freq" or primary_sweep.casefold() == "frequency":
x_key = "Freq"
x = self.rcs_data.frequencies
if secondary_sweep == "IWaveTheta":
if all_secondary_sweep_value is None:
all_secondary_sweep_value = self.rcs_data.incident_wave_theta
data = self.rcs_data.rcs_active_phi
data["Data"] = conversion_function(data["Data"], self.rcs_data.data_conversion_function)
y_key = "IWaveTheta"
else:
if all_secondary_sweep_value is None:
all_secondary_sweep_value = self.rcs_data.incident_wave_phi
data = self.rcs_data.rcs_active_theta
data["Data"] = conversion_function(data["Data"], self.rcs_data.data_conversion_function)
y_key = "IWavePhi"
else:
data = self.rcs_data.rcs_active_frequency
if primary_sweep.casefold() == "iwavephi":
x_key = "IWavePhi"
y_key = "IWaveTheta"
x = self.rcs_data.available_incident_wave_phi
if isinstance(secondary_sweep_value, str) and secondary_sweep_value == "all":
all_secondary_sweep_value = self.rcs_data.available_incident_wave_theta
elif secondary_sweep_value is None:
all_secondary_sweep_value = self.rcs_data.incident_wave_theta
else:
x_key = "IWaveTheta"
y_key = "IWavePhi"
x = self.rcs_data.available_incident_wave_theta
if isinstance(secondary_sweep_value, str) and secondary_sweep_value == "all":
all_secondary_sweep_value = self.rcs_data.available_incident_wave_phi
elif secondary_sweep_value is None:
all_secondary_sweep_value = self.rcs_data.incident_wave_phi
if all_secondary_sweep_value is not None:
if not isinstance(all_secondary_sweep_value, np.ndarray) and not isinstance(
all_secondary_sweep_value, list
):
all_secondary_sweep_value = [all_secondary_sweep_value]
for el in all_secondary_sweep_value:
data_sweep = data[data[y_key] == el]["Data"]
y = data_sweep.values
if not isinstance(y, np.ndarray): # pragma: no cover
raise Exception("Format of quantity is wrong.")
curves.append([x, y, "{}={}".format(y_key, el)])
if curves is not None:
new = ReportPlotter()
new.show_legend = show_legend
new.title = title
new.size = size
if is_polar:
props = {"x_label": x_key, "y_label": "RCS \n"}
for pdata in curves:
name = pdata[2] if len(pdata) > 2 else "Trace"
new.add_trace(pdata[:2], 0, props, name=name)
_ = new.plot_polar(traces=None, snapshot_path=output_file, show=show)
else:
from ansys.aedt.core.generic.constants import CSS4_COLORS
k = 0
for data in curves:
props = {"x_label": x_key, "y_label": "RCS", "line_color": list(CSS4_COLORS.keys())[k]}
k += 1
if k == len(list(CSS4_COLORS.keys())): # pragma: no cover
k = 0
name = data[2] if len(data) > 2 else "Trace"
new.add_trace(data[:2], 0, props, name)
_ = new.plot_2d(None, output_file, show)
return new
[docs]
@pyaedt_function_handler()
def plot_rcs_3d(self, title="Monostatic RCS 3D", output_file=None, show=True, size=(1920, 1440)):
"""Create a 3D plot of the monostatic RCS.
Parameters
----------
title : str, default: ``"RectangularPlot"``
Plot title.
output_file : str, default: ``None``
Full path for the image file. The default is ``None``, in which case an image is not exported.
show : bool, default: ``True``
Whether to show the plot. If ``False``, the Matplotlib instance of the plot is shown.
size : tuple, default: (1920, 1440)
Image size in pixel (width, height).
Returns
-------
:class:`matplotlib.pyplot.Figure`
Matplotlib figure object.
If ``show=True``, a Matplotlib figure instance of the plot is returned.
If ``show=False``, the plotted curve is returned.
"""
data = self.rcs_data.rcs_active_frequency
rcs = data["Data"]
rcs_max = np.max(rcs)
rcs_min = np.min(rcs)
rcs_renorm = rcs + np.abs(rcs_min) if rcs_min else rcs
rcs_renorm = rcs_renorm.to_numpy()
# Negative values are not valid, this is cleaning numerical issues
rcs_renorm[rcs_renorm < 0] = 0.0
theta = np.deg2rad(data["IWaveTheta"])
phi = np.deg2rad(data["IWavePhi"])
unique_phi, unique_theta = np.unique(phi), np.unique(theta)
phi_grid, theta_grid = np.meshgrid(unique_phi, unique_theta)
r = np.full(phi_grid.shape, np.nan)
idx_theta = np.digitize(theta, unique_theta) - 1
idx_phi = np.digitize(phi, unique_phi) - 1
r[idx_theta, idx_phi] = rcs_renorm
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)
new = ReportPlotter()
new.show_legend = True
new.title = title
new.size = size
quantity = f"RCS {self.rcs_data.data_conversion_function}"
props = {"x_label": "IWaveTheta", "y_label": "IWavePhi", "z_label": quantity}
new.add_trace([x, y, z], 2, props, title)
_ = new.plot_3d(trace=0, snapshot_path=output_file, show=show, color_map_limits=[rcs_min, rcs_max])
return new
[docs]
@pyaedt_function_handler()
def plot_range_profile(
self,
title="Range profile",
output_file=None,
show=True,
show_legend=True,
size=(1920, 1440),
):
"""Create a 2D plot of the range profile.
Parameters
----------
title : str, default: ``"RectangularPlot"``
Plot title.
output_file : str, default: ``None``
Full path for the image file. The default is ``None``, in which case an image is not exported.
show : bool, default: ``True``
Whether to show the plot. If ``False``, the Matplotlib instance of the plot is shown.
show_legend : bool, default: ``True``
Whether to display the legend.
size : tuple, default: (1920, 1440)
Image size in pixel (width, height).
Returns
-------
:class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
PyAEDT matplotlib figure object.
"""
data_range_profile = self.rcs_data.range_profile
ranges = np.unique(data_range_profile["Range"])
phi = self.rcs_data.incident_wave_phi
theta = self.rcs_data.incident_wave_theta
y = data_range_profile["Data"].to_numpy()
legend = f"Phi={np.round(phi, 3)} Theta={np.round(theta, 3)}"
curve = [ranges.tolist(), y.tolist(), legend]
new = ReportPlotter()
new.show_legend = show_legend
new.title = title
new.size = size
props = {"x_label": "Range (m)", "y_label": f"Range Profile ({self.rcs_data.data_conversion_function})"}
name = curve[2]
new.add_trace(curve[:2], 0, props, name)
_ = new.plot_2d(None, output_file, show)
return new
[docs]
@pyaedt_function_handler()
def plot_waterfall(
self, title="Waterfall", output_file=None, show=True, is_polar=False, size=(1920, 1440), figure=None
):
"""Create a 2D contour plot of the waterfall.
Parameters
----------
title : str, default: ``"RectangularPlot"``
Plot title.
output_file : str, default: ``None``
Full path for the image file. The default is ``None``, in which case an image is not exported.
show : bool, default: ``True``
Whether to show the plot. If ``False``, the Matplotlib instance of the plot is shown.
is_polar : bool, default: ``True``
Whether to display in polar coordinates.
size : tuple, default: (1920, 1440)
Image size in pixel (width, height).
figure : :class:`matplotlib.pyplot.Figure`, default: ``None``
An existing Matplotlib figure to add the plot to.
If no figure is provided, new ``Figure`` and ``Axes`` objects are created.
Returns
-------
:class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
PyAEDT Matplotlib figure object.
"""
data_range_waterfall = self.rcs_data.waterfall
ranges = np.unique(data_range_waterfall["Range"])
if self.rcs_data.aspect_range == "Horizontal":
phis = np.unique(data_range_waterfall["IWavePhi"])
ylabel = "Phi (deg)"
else:
phis = np.unique(data_range_waterfall["IWaveTheta"])
ylabel = "Theta (deg)"
phis = np.deg2rad(phis.tolist()) if is_polar else phis
n_range = len(ranges)
n_phi = len(phis)
values = data_range_waterfall["Data"].to_numpy()
values = values.reshape((n_range, n_phi), order="F")
ra, ph = np.meshgrid(ranges, phis)
if is_polar:
x = ph
y = ra
xlabel = " "
ylabel = " "
else:
x = ra
y = ph
xlabel = "Range (m)"
plot_data = [values.T, y, x]
new = ReportPlotter()
new.size = size
new.show_legend = False
new.title = title
props = {
"x_label": xlabel,
"y_label": ylabel,
}
new.add_trace(plot_data, 0, props)
_ = new.plot_contour(
trace=0,
polar=is_polar,
snapshot_path=output_file,
show=show,
figure=figure,
is_spherical=False,
max_theta=ra.max(),
min_theta=ra.min(),
)
return new
[docs]
@pyaedt_function_handler()
def plot_isar_2d(self, title="ISAR", output_file=None, show=True, size=(1920, 1440), figure=None):
"""Create a 2D pcolor plot of the 2D ISAR image.
Parameters
----------
title : str, default: ``"ISAR"``
Plot title.
output_file : str, default: ``None``
Full path for the image file. The default is ``None``, in which case an image is not exported.
show : bool, default: ``True``
Whether to show the plot. If ``False``, the Matplotlib instance of the plot is shown.
size : tuple, default: (1920, 1440)
Image size in pixel (width, height).
figure : :class:`matplotlib.pyplot.Figure`, default: ``None``
An existing Matplotlib figure to add the plot to.
If no figure is provided, new ``Figure`` and ``Axes`` objects are created.
Returns
-------
:class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
PyAEDT matplotlib figure object.
"""
data_isar = self.rcs_data.isar_2d
ranges = np.unique(data_isar["Down-range"])
phis = np.unique(data_isar["Cross-range"])
n_range = len(ranges)
n_phi = len(phis)
values = data_isar["Data"].to_numpy().reshape((n_range, n_phi))
x, y = np.meshgrid(phis, ranges) # important, do not use ij here
xlabel = "Range (m)"
ylabel = "Cross Range (m)"
plot_data = [values, x, y]
new = ReportPlotter()
new.size = size
new.show_legend = False
new.title = title
props = {
"x_label": xlabel,
"y_label": ylabel,
}
new.add_trace(plot_data, 0, props)
_ = new.plot_pcolor(trace=0, snapshot_path=output_file, show=show, figure=figure)
return new
[docs]
@pyaedt_function_handler()
def plot_isar_3d(
self,
plane_cut: str = "xz",
plane_offset: float = 0,
title="ISAR 3D cut",
output_file=None,
show=True,
size=(1920, 1440),
figure=None,
):
"""Create a 2D pcolor plot of a 3D-ISAR cut.
Parameters
----------
title : str, default: ``"ISAR 3D cut"``
Plot title.
plane_cut : str, default: ``"xz"``
Cut to be plotted. Options are ``"xz"``, ``"yz"``, and ``"xy"``.
plane_offset : float, default: ``0``
Offset of the plane to plot. (It finds the closest available.)
output_file : str, default: ``None``
Full path for the image file. The default is ``None``, in which case an image in not exported.
show : bool, default: ``True``
Whether to show the plot. If ``False``, the Matplotlib instance of the plot is shown.
size : tuple, default: (1920, 1440)
Image size in pixel (width, height).
figure : :class:`matplotlib.pyplot.Figure`, default: ``None``
An existing Matplotlib figure to add the plot to.
If no figure is provided, new ``Figure`` and ``Axes`` objects are created.
Returns
-------
:class:`ansys.aedt.core.visualization.plot.matplotlib.ReportPlotter`
PyAEDT matplotlib figure object.
"""
# implementation for plane cut plot
if plane_cut is None:
raise ValueError("Please provide a plane cut type.")
if plane_offset is None:
raise ValueError("Please provide a plane offset value.")
data_isar_3d = self.rcs_data.isar_3d
down_range = data_isar_3d["Down-range"].unique()
cross_range_az = data_isar_3d["Cross-range-az"].unique()
cross_range_el = data_isar_3d["Cross-range-el"].unique()
# The above pivot may need to be adjusted depending on the data layout
# For now, let's try to reshape the data
try:
values_3d = data_isar_3d["Data"].to_numpy()
values_3d = values_3d.reshape((len(down_range), len(cross_range_az), len(cross_range_el)))
except Exception: # pragma: no cover
# fallback: fill with zeros
values_3d = np.zeros((len(down_range), len(cross_range_az), len(cross_range_el)))
if plane_cut.casefold() == "xy":
idx_fixed_range = (np.abs(cross_range_el - plane_offset)).argmin()
values = values_3d[:, :, idx_fixed_range]
y = down_range
x = cross_range_az
xlabel = "Down-Range (m)"
ylabel = "Cross Range (az) (m)"
elif plane_cut.casefold() == "xz":
idx_fixed_range = (np.abs(cross_range_az - plane_offset)).argmin()
values = values_3d[:, idx_fixed_range, :]
y = down_range
x = cross_range_el
xlabel = "Down-Range (m)"
ylabel = "Cross Range (el) (m)"
elif plane_cut.casefold() == "yz":
idx_fixed_range = (np.abs(down_range - plane_offset)).argmin()
values = values_3d[idx_fixed_range, :, :]
y = cross_range_az
x = cross_range_el
xlabel = "Cross Range (az) (m)"
ylabel = "Cross Range (el) (m)"
else:
raise ValueError("Invalid plane cut. Choose 'xy', 'xz', or 'yz'.")
x_, y_ = np.meshgrid(x, y) # important, do not use ij here
plot_data = [values, x_, y_]
new = ReportPlotter()
new.size = size
new.show_legend = False
new.title = title
props = {
"x_label": xlabel,
"y_label": ylabel,
}
new.add_trace(plot_data, 0, props)
_ = new.plot_pcolor(trace=0, snapshot_path=output_file, show=show, figure=figure)
return new
[docs]
@pyaedt_function_handler()
@graphics_required
def plot_scene(self, show=True):
"""Plot the 3D scene including models, annotations, and results.
This method visualizes the 3D scene by rendering the mesh objects under the "model",
"annotations", and "results" categories stored in `self.all_scene_actors`. The meshes
are rendered using a default PyVista plotter.
Parameters
----------
show : bool, default: ``True``
Whether to immediately display the plot using
the ``plotter.show()`` method. If ``False``, the
``plotter`` object is returned for further
customization before rendering.
Returns
-------
pyvista.Plotter or None
Returns the ``Plotter`` object if ``show`` is set to ``False``. If ``show`` is set to ``True``,
the plot is displayed and no value is returned.
"""
pv_backend = PyVistaBackend(allow_picking=True, plot_picked_names=True)
plotter = Plotter(backend=pv_backend)
if self.show_geometry:
for geo in self.all_scene_actors["model"].values():
self.__add_mesh(geo, plotter, "model")
for annotations in self.all_scene_actors["annotations"]:
for annotation in self.all_scene_actors["annotations"][annotations].values():
if annotation.custom_object.show:
self.__add_mesh(annotation, plotter, "annotations")
for all_scene_results in self.all_scene_actors["results"]:
for result_actor in self.all_scene_actors["results"][all_scene_results].values():
self.__add_mesh(result_actor, plotter, "results")
plotter.backend.scene.show_grid(
location="all",
xtitle=f"X Axis ({self.model_units})",
ytitle=f"Y Axis ({self.model_units})",
ztitle=f"Z Axis ({self.model_units})",
)
if show: # pragma: no cover
plotter.show()
else:
return plotter
[docs]
@pyaedt_function_handler()
@graphics_required
def add_rcs(
self,
color_bar="jet",
):
"""Add a 3D RCS representation to the current scene.
This function normalizes and visualizes RCS data on a spherical coordinate grid
(theta, phi), mapping it to 3D Cartesian coordinates (x, y, z). The RCS values are
color-mapped and added as a mesh to the current scene actors.
Parameters
----------
color_bar : str, default: ``"jet"``
Color mapping to apply to the RCS data. It can be a
color (such as ``"blue"`` or ``"green"``) or a
colormap (such as ``"jet"`` or ``"viridis"``.
"""
data = self.rcs_data.rcs_active_frequency
new_data = self.stretch_data(
data, scaling_factor=max(self.extents[5], 0.0) - min(self.extents[4], 0.0), offset=0.0
)
rcs = new_data["Data"]
rcs_min = np.min(rcs)
rcs_renorm = rcs + np.abs(rcs_min) if rcs_min else rcs
rcs_renorm = rcs_renorm.to_numpy()
# Negative values are not valid, this is cleaning numerical issues
rcs_renorm[rcs_renorm < 0] = 0.0
theta = np.deg2rad(data["IWaveTheta"])
phi = np.deg2rad(data["IWavePhi"])
unique_phi, unique_theta = np.unique(phi), np.unique(theta)
phi_grid, theta_grid = np.meshgrid(unique_phi, unique_theta)
r = np.full(phi_grid.shape, np.nan)
idx_theta = np.digitize(theta, unique_theta) - 1
idx_phi = np.digitize(phi, unique_phi) - 1
r[idx_theta, idx_phi] = rcs_renorm
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)
actor = pv.StructuredGrid(x, y, z)
actor.point_data["values"] = data["Data"]
all_results_actors = list(self.all_scene_actors["results"].keys())
if "rcs" not in all_results_actors:
self.all_scene_actors["results"]["rcs"] = {}
index = 0
while f"rcs_{index}" in self.all_scene_actors["results"]["rcs"]:
index += 1
rcs_name = f"rcs_{index}"
rcs_object = SceneMeshObject()
rcs_object.name = rcs_name
rcs_object.line_width = 1.0
scalar_dict = dict(color="#000000", title=f"RCS {index} [{self.rcs_data.data_conversion_function}]")
rcs_object.scalar_dict = scalar_dict
if any(color_bar in x for x in ["blue", "green", "black", "red"]):
rcs_object.color = color_bar
else:
rcs_object.color_map = color_bar
rcs_object.mesh = actor
rcs_mesh = MeshObjectPlot(rcs_object, rcs_object.get_mesh())
self.all_scene_actors["results"]["rcs"][rcs_name] = rcs_mesh
[docs]
@pyaedt_function_handler()
@graphics_required
def add_range_profile_settings(
self,
size_range=10.0,
range_resolution=0.1,
tick_color="#000000",
line_color="#ff0000",
disc_color="ff0000",
cone_color="#00ff00",
):
"""Add a 3D range profile setting representation to the current scene.
This function visualizes a 3D range profile with a
main line representing the range axis and tick marks
indicating distance intervals. The profile includes
visual elements like a disc at the far end and a
cone at the starting point. These elements help to
display a reference range profile in the 3D scene.
Parameters
----------
size_range : float, default: ``10.0``
Total size of the range in meters. It determines the length of the range profile.
range_resolution : float, default: ``0.1``
Resolution of the range in meters, representing the distance between each tick mark along the range profile.
tick_color : str, default: ``"#000000"``
Color of the tick marks along the range profile. The ``"#000000"`` default is black.
line_color : str, default: ``"#ff0000"``
Color of the line. The ``"#ff0000"`` is red.
disc_color : str, default: ``"#ff0000"``
Color of the disc. The ``"#ff0000"`` is red.
cone_color : str, default: ``"#00ff00"``
Color of the cone. The d``"#00ff00"`` is green.
"""
size_range = unit_converter(
size_range, unit_system="Length", input_units="meter", output_units=self.model_units
)
range_resolution = unit_converter(
range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
)
# Compute parameters
range_max = size_range - range_resolution
range_num = int(np.round(size_range / range_resolution))
distance_range = np.linspace(0, range_max, range_num)
distance_range -= distance_range[range_num // 2]
range_first = -distance_range[0]
range_last = -distance_range[-1]
num_ticks = int(size_range / range_resolution)
# Using 5% of total range length
tick_length = size_range * 0.05
if "range_profile" not in self.all_scene_actors["annotations"]:
self.all_scene_actors["annotations"]["range_profile"] = {}
# TODO: Do we want to support non-centered Range profile?
center = np.array([0.0, 0.0, 0.0])
# Main red line
name = "main_line"
main_line_az_mesh = self._create_line(
pointa=(range_first + center[0], self.extents[2] * 10 + center[1], center[2]),
pointb=(range_last + center[0], self.extents[2] * 10 + center[1], center[2]),
name=name,
color=line_color,
)
self.all_scene_actors["annotations"]["range_profile"][name] = main_line_az_mesh
# Ticks
tick_lines = pv.PolyData()
for tick in range(num_ticks + 1): # create line with tick marks
if tick % 1 == 0: # only do every nth tick
tick_pos_start = (
range_first - range_resolution * tick + center[0],
self.extents[2] * 10 + center[1],
center[2],
)
tick_pos_end = (
range_first - range_resolution * tick + center[0],
self.extents[2] * 10 + tick_length + center[1],
center[2],
)
tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)
annotation_name = "ticks"
tick_lines_object = SceneMeshObject()
tick_lines_object.name = annotation_name
tick_lines_object.color = tick_color
tick_lines_object.line_width = 2
tick_lines_object.mesh = tick_lines
tick_lines_mesh = MeshObjectPlot(tick_lines_object, tick_lines_object.get_mesh())
self.all_scene_actors["annotations"]["range_profile"][annotation_name] = tick_lines_mesh
start_geo = pv.Disc(
center=(range_last + center[0], self.extents[2] * 10 + center[1], center[2]),
outer=tick_length,
inner=0,
normal=(-1, 0, 0),
c_res=12,
)
annotation_name = "disc"
start_geo_object = SceneMeshObject()
start_geo_object.name = annotation_name
start_geo_object.color = disc_color
start_geo_object.line_width = 5
start_geo_object.mesh = start_geo
disc_geo_mesh = MeshObjectPlot(start_geo_object, start_geo_object.get_mesh())
self.all_scene_actors["annotations"]["range_profile"][annotation_name] = disc_geo_mesh
name = "cone"
cone_center = (range_first + center[0], self.extents[2] * 10 + center[1], center[2])
end_geo_mesh = self._create_cone(
center=cone_center,
direction=(-1, 0, 0),
radius=tick_length,
height=tick_length * 2,
resolution=12,
name=name,
color=cone_color,
)
self.all_scene_actors["annotations"]["range_profile"][name] = end_geo_mesh
[docs]
@pyaedt_function_handler()
@graphics_required
def add_waterfall_settings(
self, aspect_ang_phi=360.0, phi_num=10, tick_color="#000000", line_color="#ff0000", cone_color="#00ff00"
):
"""
Add a 3D waterfall setting representation to the current scene.
This function visualizes a 3D waterfall pattern that
represents angular data across a circular arc in a
spherical coordinate system. The arc covers an angular
extent defined by the ``aspect_ang_phi`` parameter, with
optional tick marks along the arc. A cone is added to
indicate the endpoint of the angular sweep.
Parameters
----------
aspect_ang_phi : float, default: ``360.0``
Angular extent of the arc in degrees. It defines
the total angle over which the circular arc spans.
The ``360.0`` default is a full circle.
phi_num : int, default: ``10``
Number of tick marks to place along the arc.
tick_color : str, default: ``"#000000"``
Color of the tick marks. The ``"#000000"`` default is black.
line_color : str, default: ``"#ff0000"``
Color of the line. The ``"#ff0000"`` default is red.
cone_color : str, default: ``"#00ff00"``
Color of the cone. The ``"#00ff00"`` default is green.
"""
radius_max = self.radius
# TODO: Do we want to support non-centered waterfall?
center = np.array([0.0, 0.0, 0.0])
angle = aspect_ang_phi - 1
if "waterfall" not in self.all_scene_actors["annotations"]:
self.all_scene_actors["annotations"]["waterfall"] = {}
# Circular Arc
x_start = center[0] + radius_max
y_start = center[1]
start_point = (x_start, y_start, center[2])
end_point = [
center[0] + radius_max * np.cos(np.deg2rad(angle)),
center[1] + radius_max * np.sin(np.deg2rad(angle)),
center[2],
]
negative = False
if angle >= 180:
negative = True
name = "arc"
arc_mesh = self._create_arc(
pointa=start_point,
pointb=end_point,
center=center,
resolution=100,
negative=negative,
name="arc",
color=line_color,
)
self.all_scene_actors["annotations"]["waterfall"][name] = arc_mesh
# Ticks
tick_spacing_deg = int(aspect_ang_phi) / phi_num
if tick_spacing_deg >= 1: # gets too cluttered if too small of spacing
tick_lines = pv.PolyData()
for tick in range(phi_num + 1): # create line with tick marks
if tick % 1 == 0: # only do every nth tick
x_start = center[0] + radius_max * 0.95 * np.cos(np.deg2rad(tick * tick_spacing_deg))
y_start = center[1] + radius_max * 0.95 * np.sin(np.deg2rad(tick * tick_spacing_deg))
x_stop = center[0] + radius_max * 1.05 * np.cos(np.deg2rad(tick * tick_spacing_deg))
y_stop = center[1] + radius_max * 1.05 * np.sin(np.deg2rad(tick * tick_spacing_deg))
tick_pos_start = (x_start, y_start, center[2])
tick_pos_end = (x_stop, y_stop, center[2])
tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)
annotation_name = "ticks"
tick_lines_object = SceneMeshObject()
tick_lines_object.name = annotation_name
tick_lines_object.color = tick_color
tick_lines_object.line_width = 2
tick_lines_object.mesh = tick_lines
tick_lines_mesh = MeshObjectPlot(tick_lines_object, tick_lines_object.get_mesh())
self.all_scene_actors["annotations"]["waterfall"][annotation_name] = tick_lines_mesh
end_point = [
center[0] + radius_max * np.cos(np.deg2rad(angle)),
center[1] + radius_max * np.sin(np.deg2rad(angle)),
center[2],
]
end_point_plus_one = [
center[0] + radius_max * np.cos(np.deg2rad(aspect_ang_phi)),
center[1] + radius_max * np.sin(np.deg2rad(aspect_ang_phi)),
center[2],
]
direction = np.array(end_point_plus_one) - np.array(end_point)
direction_mag = np.linalg.norm(direction)
name = "cone"
end_geo_mesh = self._create_cone(
center=end_point,
direction=direction,
radius=direction_mag * 2,
height=direction_mag * 4,
resolution=12,
name=name,
color=cone_color,
)
self.all_scene_actors["annotations"]["waterfall"][name] = end_geo_mesh
[docs]
@pyaedt_function_handler()
@graphics_required
def add_isar_2d_settings(
self,
size_range=10.0,
range_resolution=0.1,
size_cross_range=10.0,
cross_range_resolution=0.1,
tick_color="#000000",
line_color="#ff0000",
):
"""
Add a preview frame of 2D ISAR visualization to the current 3D scene.
Parameters
----------
size_range : float, default: ``10.0``
Total size of the range axis in meters. This sets the overall length of the range axis.
range_resolution : float, default: ``0.1``
Resolution of the range axis in meters, specifying the
spacing between each tick mark.
size_cross_range : float, default: ``10.0``
Total size of the cross-range axis in meters. This sets the width of the cross-range axis.
cross_range_resolution : float, default: ``0.1``
Resolution of the cross-range axis in meters, specifying
the spacing between each tick mark along the azimuth axis.
tick_color : str, default: ``"#000000"``
Color of the tick marks along both the range and
cross-range axes. The "#000000" default is black.
line_color : str, default: ``"#ff0000"``
Color of the line. The ``"#ff0000"`` default is red.
"""
size_range = unit_converter(
size_range, unit_system="Length", input_units="meter", output_units=self.model_units
)
range_resolution = unit_converter(
range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
)
size_cross_range = unit_converter(
size_cross_range, unit_system="Length", input_units="meter", output_units=self.model_units
)
cross_range_resolution = unit_converter(
cross_range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
)
center = np.array([0.0, 0.0, 0.0])
range_ticks, range_frame, num_ticks = self._compute_axis_parameters(size_range, range_resolution, center[0])
range_ticks_az, range_frame_az, num_ticks_az = self._compute_axis_parameters(
size_cross_range, cross_range_resolution, center[1]
)
tick_length = size_range * 0.05
tick_length_az = size_cross_range * 0.05
if "isar_2d" not in self.all_scene_actors["annotations"]:
self.all_scene_actors["annotations"]["isar_2d"] = {}
# Create main lines
main_lines = self._create_main_lines(range_frame, range_frame_az, center, line_color)
self.all_scene_actors["annotations"]["isar_2d"].update(main_lines)
# Add range ticks
tick_lines = pv.PolyData()
for tick in range(0, num_ticks, num_ticks // 64 + 1): # create line with tick marks
tick_pos_start = (
range_ticks[tick] + center[0],
range_frame_az[0] + center[1],
center[2],
)
tick_pos_end = (
range_ticks[tick] + center[0],
range_frame_az[0] - tick_length + center[1],
center[2],
)
tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)
annotation_name = "ticks_range"
tick_lines_range_object = SceneMeshObject()
tick_lines_range_object.name = annotation_name
tick_lines_range_object.color = tick_color
tick_lines_range_object.line_width = 2
tick_lines_range_object.mesh = tick_lines
tick_lines_range_mesh = MeshObjectPlot(tick_lines_range_object, tick_lines_range_object.get_mesh())
self.all_scene_actors["annotations"]["isar_2d"][annotation_name] = tick_lines_range_mesh
# Add azimuth ticks
tick_lines = pv.PolyData()
for tick in range(0, num_ticks_az, num_ticks_az // 64 + 1): # create line with tick marks
tick_pos_start = (range_frame[-1] + center[0], range_ticks_az[tick] + center[1], center[2])
tick_pos_end = (
range_frame[-1] + tick_length_az + center[0],
range_ticks_az[tick] + center[1],
center[2],
)
tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)
annotation_name = "ticks_az"
tick_lines_az_object = SceneMeshObject()
tick_lines_az_object.name = annotation_name
tick_lines_az_object.color = tick_color
tick_lines_az_object.line_width = 2
tick_lines_az_object.mesh = tick_lines
tick_lines_az_mesh = MeshObjectPlot(tick_lines_az_object, tick_lines_az_object.get_mesh())
self.all_scene_actors["annotations"]["isar_2d"][annotation_name] = tick_lines_az_mesh
@staticmethod
def _compute_axis_parameters(size, resolution, center):
"""Compute axis parameters for ISAR settings."""
axis_max = size - resolution
axis_num = int(np.round(size / resolution))
axis_ticks = np.linspace(0, axis_max, axis_num)
axis_ticks -= (axis_ticks[-1] - axis_ticks[0]) / 2 + center
axis_frame = np.array([-size / 2, size / 2])
return axis_ticks, axis_frame, axis_num
def _create_main_lines(self, range_frame, range_frame_az, center, line_color):
"""Create main lines for ISAR 2D settings."""
main_lines = {}
main_lines["main_line"] = self._create_line(
pointa=(range_frame[0] + center[0], range_frame_az[0] + center[1], center[2]),
pointb=(range_frame[-1] + center[0], range_frame_az[0] + center[1], center[2]),
name="main_line",
color=line_color,
)
main_lines["main_line_opposite"] = self._create_line(
pointa=(range_frame[0] + center[0], range_frame_az[-1] + center[1], center[2]),
pointb=(range_frame[-1] + center[0], range_frame_az[-1] + center[1], center[2]),
name="main_line_opposite",
color=line_color,
)
main_lines["main_line_az"] = self._create_line(
pointa=(range_frame[0] + center[0], range_frame_az[0] + center[1], center[2]),
pointb=(range_frame[0] + center[0], range_frame_az[-1] + center[1], center[2]),
name="main_line_az",
color=line_color,
)
main_lines["main_line_az_opposite"] = self._create_line(
pointa=(range_frame[1] + center[0], range_frame_az[0] + center[1], center[2]),
pointb=(range_frame[1] + center[0], range_frame_az[1] + center[1], center[2]),
name="main_line_az_opposite",
color=line_color,
)
return main_lines
[docs]
@pyaedt_function_handler()
@graphics_required
def add_isar_3d_settings(
self,
size_range=10.0,
range_resolution=0.1,
size_cross_range=10.0,
cross_range_resolution=0.1,
size_elevation_range=10.0,
elevation_range_resolution=0.1,
tick_color="#000000",
line_color="#ff0000",
):
"""
Add a preview frame of 3D ISAR visualization to the current 3D scene.
Parameters
----------
size_range : float, default: ``10.0``
Total size of the range axis in meters. This sets the
overall length of the range axis.
range_resolution : float, default: ``0.1``
Resolution of the range axis in meters, specifying
the spacing between each tick mark.
size_cross_range : float, default: ``10.0``
Total size of the cross-range axis in meters. This
sets the width of the cross-range axis.
cross_range_resolution : float, default: ``0.1``
Resolution of the cross-range axis in meters, specifying
the spacing between each tick mark along the
azimuth axis.
size_elevation_range : float, default: ``10.0``
Total size of the elevation-range axis in meters. This
sets the width of the elevation-range axis.
elevation_range_resolution : float, default: ``0.1``
Resolution of the elevation-range axis in meters,
specifying the spacing between each tick mark along the elevation axis.
tick_color : str, default: ``"#000000"``
Color of the tick marks along both the range and
cross-range axes. The ``"#000000"`` default is black.
line_color : str, default: ``"#ff0000"``
Color of the line. The ``"#ff0000"`` default is red.
"""
size_range = unit_converter(
size_range, unit_system="Length", input_units="meter", output_units=self.model_units
)
range_resolution = unit_converter(
range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
)
size_cross_range = unit_converter(
size_cross_range, unit_system="Length", input_units="meter", output_units=self.model_units
)
cross_range_resolution = unit_converter(
cross_range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
)
size_elevation_range = unit_converter(
size_elevation_range, unit_system="Length", input_units="meter", output_units=self.model_units
)
elevation_range_resolution = unit_converter(
elevation_range_resolution, unit_system="Length", input_units="meter", output_units=self.model_units
)
center = np.array([0.0, 0.0, 0.0])
range_ticks, range_frame, num_ticks = self._compute_axis_parameters(size_range, range_resolution, center[0])
range_ticks_az, range_frame_az, num_ticks_az = self._compute_axis_parameters(
size_cross_range, cross_range_resolution, center[1]
)
range_ticks_el, range_frame_el, num_ticks_el = self._compute_axis_parameters(
size_elevation_range, elevation_range_resolution, center[2]
)
tick_length = size_range * 0.05
tick_length_az = size_cross_range * 0.05
tick_length_el = size_elevation_range * 0.05
if "isar_3d" not in self.all_scene_actors["annotations"]:
self.all_scene_actors["annotations"]["isar_3d"] = {}
# Create main lines
main_lines = self._create_main_lines(range_frame, range_frame_az, center, line_color)
self.all_scene_actors["annotations"]["isar_3d"].update(main_lines)
# Add elevation lines
name = "main_line_el"
main_line_el_mesh = self._create_line(
pointa=(range_frame[0] + center[0], range_frame_az[0] + center[1], range_frame_el[0] + center[2]),
pointb=(range_frame[0] + center[0], range_frame_az[0] + center[1], range_frame_el[-1] + center[2]),
name=name,
color=line_color,
)
self.all_scene_actors["annotations"]["isar_3d"][name] = main_line_el_mesh
# Add bounding box
box_bounds = (
range_frame[0] + center[0],
range_frame[-1] + center[0],
range_frame_az[0] + center[1],
range_frame_az[-1] + center[1],
range_frame_el[0] + center[2],
range_frame_el[-1] + center[2],
)
name = "box"
box = pv.Box(box_bounds)
box_object = SceneMeshObject()
box_object.name = name
box_object.color = line_color
box_object.opacity = 0.05
box_object.line_width = 5
box_object.show_edges = True
box_object.edge_color = line_color
box_object.mesh = box
box_mesh = MeshObjectPlot(box_object, box_object.get_mesh())
self.all_scene_actors["annotations"]["isar_3d"][name] = box_mesh
# Add range ticks
if num_ticks <= 256:
tick_lines = pv.PolyData()
for tick in range(1, num_ticks, 2):
tick_pos_start = (
range_ticks[tick] + center[0],
range_frame_az[0] + center[1],
center[2],
)
tick_pos_end = (
range_ticks[tick] + center[0],
range_frame_az[0] - tick_length + center[1],
center[2],
)
tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)
annotation_name = "ticks_range"
tick_lines_range_object = SceneMeshObject()
tick_lines_range_object.name = annotation_name
tick_lines_range_object.color = tick_color
tick_lines_range_object.line_width = 2
tick_lines_range_object.mesh = tick_lines
tick_lines_range_mesh = MeshObjectPlot(tick_lines_range_object, tick_lines_range_object.get_mesh())
self.all_scene_actors["annotations"]["isar_3d"][annotation_name] = tick_lines_range_mesh
# Add azimuth ticks
if num_ticks_az <= 256:
tick_lines = pv.PolyData()
for tick in range(1, num_ticks_az - 1, 2):
tick_pos_start = (range_frame[0] + center[0], range_ticks_az[tick] + center[1], center[2])
tick_pos_end = (
range_frame[0] + tick_length_az + center[0],
range_ticks_az[tick] + center[1],
center[2],
)
tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)
annotation_name = "ticks_az"
tick_lines_az_object = SceneMeshObject()
tick_lines_az_object.name = annotation_name
tick_lines_az_object.color = tick_color
tick_lines_az_object.line_width = 2
tick_lines_az_object.mesh = tick_lines
tick_lines_az_mesh = MeshObjectPlot(tick_lines_az_object, tick_lines_az_object.get_mesh())
self.all_scene_actors["annotations"]["isar_3d"][annotation_name] = tick_lines_az_mesh
# Add elevation ticks
if num_ticks_el <= 256:
tick_lines = pv.PolyData()
for tick in range(1, num_ticks_el - 1, 2):
tick_pos_start = (
range_frame[0] + center[0],
range_frame_az[0] + center[1],
range_ticks_el[tick] + center[2],
)
tick_pos_end = (
range_frame[0] + tick_length_el + center[0],
range_frame_az[0] + center[1],
range_ticks_el[tick] + center[2],
)
tick_lines += pv.Line(pointa=tick_pos_start, pointb=tick_pos_end)
annotation_name = "ticks_el"
tick_lines_el_object = SceneMeshObject()
tick_lines_el_object.name = annotation_name
tick_lines_el_object.color = tick_color
tick_lines_el_object.line_width = 2
tick_lines_el_object.mesh = tick_lines
tick_lines_el_mesh = MeshObjectPlot(tick_lines_el_object, tick_lines_el_object.get_mesh())
self.all_scene_actors["annotations"]["isar_3d"][annotation_name] = tick_lines_el_mesh
[docs]
@pyaedt_function_handler()
@graphics_required
def add_range_profile(
self,
plot_type="Line",
color_bar="jet",
):
"""Add the 3D range profile.
This function visualizes a 3D range profile, which represents the RCS data
as a function of range. The profile is rendered as a plot in the 3D scene using spherical coordinates
(phi, theta) mapped into Cartesian coordinates (x, y). The range profile can be visualized in different
plot types such as line plots or color-mapped surfaces.
Parameters
----------
plot_type : str, default: ``"Line"``
The type of plot to create for the range profile.
Options are ``"Extruded"``, ``"Line"``, `"Plane H"``,
``"Plane V"``, `"Projection"``, ``"Ribbon"``, and ``"Rotated"``.
color_bar : str, default: ``"jet"``
Color mapping to apply to the RCS data. It can be
a color (such as ``"blue"`` or ``"green"``) or a
colormap (such as ``"jet"`` or ``"viridis"``).
"""
data_range_profile = self.rcs_data.range_profile
data_scaled = self.stretch_data(
data_range_profile["Data"],
scaling_factor=max(self.extents[5], 0.0) - min(self.extents[4], 0.0),
offset=0.0,
)
ranges = data_range_profile["Range"].to_numpy()
rotation = {}
rotation["azimuth"] = -self.rcs_data.incident_wave_phi
rotation["elevation"] = 90 - self.rcs_data.incident_wave_theta
rotation["twist"] = 0
xpos = -ranges
ypos = np.zeros_like(xpos)
if plot_type.casefold() in ["line", "ribbon", "rotated", "extruded"]:
zpos = data_scaled.to_numpy()
else:
zpos = np.zeros_like(xpos)
x, y, z = self.rotate_point(xpos, ypos, zpos, rotation)
cpos = data_range_profile["Data"].to_numpy()
vmin, vmax = np.nanmin(cpos), np.nanmax(cpos)
xvec, yvec, zvec = self.rotate_point(np.array([1, 0, 0]), np.array([0, 1, 0]), np.array([0, 0, 1]), rotation)
axis_vec = np.array([[xvec[0], yvec[0], zvec[0]], [xvec[1], yvec[1], zvec[1]], [xvec[2], yvec[2], zvec[2]]])
actor = self.__get_pyvista_range_profile_actor(
x,
y,
z,
cpos,
axis_vec,
plot_type=plot_type,
scene_actors=self.all_scene_actors["model"],
data_conversion_function=self.rcs_data.data_conversion_function,
extents=self.extents,
)
all_results_actors = list(self.all_scene_actors["results"].keys())
if "range_profile" not in all_results_actors:
self.all_scene_actors["results"]["range_profile"] = {}
index = 0
while f"range_profile_{index}" in self.all_scene_actors["results"]["range_profile"]:
index += 1
range_profile_name = f"range_profile_{index}"
range_profile_object = SceneMeshObject()
range_profile_object.name = range_profile_name
range_profile_object.line_width = 1.0
scalar_dict = dict(color="#000000", title=f"Range Profile {index} [{self.rcs_data.data_conversion_function}]")
range_profile_object.scalar_dict = scalar_dict
if any(color_bar in x for x in ["blue", "green", "black", "red"]):
range_profile_object.color = color_bar
else:
range_profile_object.color_map = color_bar
range_profile_object.mesh = actor
range_profile_object.clim = (vmin, vmax)
range_profile_object.default_clim = (vmin, vmax)
range_profile_mesh = MeshObjectPlot(range_profile_object, range_profile_object.get_mesh())
self.all_scene_actors["results"]["range_profile"][range_profile_name] = range_profile_mesh
[docs]
@pyaedt_function_handler()
@graphics_required
def add_waterfall(
self,
color_bar="jet",
):
"""Add the 3D waterfall."""
data_waterfall = self.rcs_data.waterfall
rotation = {}
if self.rcs_data.aspect_range == "Horizontal":
rotation["azimuth"] = 0
rotation["elevation"] = 90 - self.rcs_data.incident_wave_theta
rotation["twist"] = 0
angles = np.unique(data_waterfall["IWavePhi"]) # negative azimuth
elif self.rcs_data.aspect_range == "Vertical":
rotation["azimuth"] = -self.rcs_data.incident_wave_phi
rotation["elevation"] = 0
rotation["twist"] = 90
angles = np.unique(data_waterfall["IWaveTheta"]) - 90 # negative elevation
else:
raise ValueError("Plane selection is not supported. Please select 'Horizontal' or 'Vertical'.")
ranges = np.unique(data_waterfall["Range"])
values = data_waterfall["Data"].to_numpy()
angles = np.deg2rad(angles)
ra, ph = np.meshgrid(ranges, angles)
x = -ra.T * np.cos(ph.T)
y = -ra.T * np.sin(ph.T)
z = np.zeros_like(x)
# Rotate the points based on the specified rotation
x, y, z = self.rotate_point(x, y, z, rotation)
actor = pv.StructuredGrid(x, y, z)
actor.point_data["values"] = values
all_results_actors = list(self.all_scene_actors["results"].keys())
if "waterfall" not in all_results_actors:
self.all_scene_actors["results"]["waterfall"] = {}
index = 0
while f"waterfall_{index}" in self.all_scene_actors["results"]["waterfall"]:
index += 1
waterfall_name = f"waterfall_{index}"
waterfall_object = SceneMeshObject()
waterfall_object.name = waterfall_name
scalar_dict = dict(color="#000000", title=f"Waterfall {index} [{self.rcs_data.data_conversion_function}]")
waterfall_object.scalar_dict = scalar_dict
waterfall_object.color_map = color_bar
waterfall_object.mesh = actor
rcs_mesh = MeshObjectPlot(waterfall_object, waterfall_object.get_mesh())
self.all_scene_actors["results"]["waterfall"][waterfall_name] = rcs_mesh
[docs]
@pyaedt_function_handler()
@graphics_required
def add_isar_2d(
self,
plot_type="plane",
color_bar="jet",
):
"""Add the ISAR 2D.
Parameters
----------
plot_type : str, default: ``"plane"``
Type of plot to create for the range profile. Options
are ``"plane"``, `"projection"`` and ``"relief"``.
color_bar : str, default: ``"jet"``
Color mapping to apply to the RCS data. It can be a
color (such as ``"blue"`` or ``"green"``) or a colormap
(such as ``"jet"`` or ``"viridis"``).
"""
rotation = {}
if self.rcs_data.aspect_range == "Horizontal":
rotation["azimuth"] = 0
rotation["elevation"] = 90 - self.rcs_data.incident_wave_theta
rotation["twist"] = 0
elif self.rcs_data.aspect_range == "Vertical":
rotation["azimuth"] = -self.rcs_data.incident_wave_phi
rotation["elevation"] = 0
rotation["twist"] = 90
else:
raise ValueError("Plane selection is not supported. Please select 'Horizontal' or 'Vertical'.")
data_isar_2d = self.rcs_data.isar_2d
down_range = data_isar_2d["Down-range"].unique()
cross_range = data_isar_2d["Cross-range"].unique()
values_2d = data_isar_2d["Data"].to_numpy().reshape((len(down_range), len(cross_range)))
if plot_type.casefold() in ["relief", "plane"]:
# meshgrid must have one more pixel. In the other words, number of meshgrid points = number of values + 1
# mesh idx 1 2 3 4 5
# | * | * | * | * |
# value idx 1 2 3 4
dx = down_range[1] - down_range[0]
down_range_grid = np.linspace(down_range[0] - dx / 2, down_range[-1] + dx / 2, num=len(down_range) + 1)
dy = cross_range[1] - cross_range[0]
cross_range_grid = np.linspace(cross_range[0] - dy / 2, cross_range[-1] + dy / 2, num=len(cross_range) + 1)
x, y = np.meshgrid(down_range_grid[::-1], cross_range_grid, indexing="ij")
z = np.zeros_like(x)
if plot_type.casefold() == "relief":
m = 2.0
b = -1.0
# z = (values_2d - values_2d.min()) / (values_2d.max() - values_2d.min()) * m + b
f_z = RegularGridInterpolator(
(-down_range, cross_range), values_2d, fill_value=None, method="linear", bounds_error=False
)
z = f_z((x, y))
z = (z - z.min()) / (z.max() - z.min()) * m + b
else:
# Projection
x, y = np.meshgrid(down_range[::-1], cross_range, indexing="ij")
z = np.zeros_like(x)
# Rotate the points based on the specified rotation
x, y, z = self.rotate_point(x, y, z, rotation)
vmin, vmax = np.nanmin(values_2d), np.nanmax(values_2d)
if plot_type.casefold() in ["relief", "plane"]:
actor = pv.StructuredGrid(x, y, z)
actor["values"] = values_2d.ravel(order="F")
else: # Projection
scene_actors = self.all_scene_actors["model"]
if scene_actors is None: # pragma: no cover
return None
actor = pv.PolyData()
# Prepare grid geometry for fast nearest neighbor search
# x, y, z are meshgrid arrays (down_range, cross_range, 0), shape (N, M)
# The grid is in the (x, y) plane, z=0
# Grid origin is the (x[0,0], y[0,0], z[0,0])
cpos = values_2d.flatten()
isar_points = np.column_stack((x.flatten(), y.flatten(), z.flatten()))
grid_origin = isar_points[0, :]
# Basis vectors: u = (x[1,0] - x[0,0], y[1,0] - y[0,0], z[1,0] - z[0,0])
# v = (x[0,1] - x[0,0], y[0,1] - y[0,0], z[0,1] - z[0,0])
if x.shape[0] > 1:
u = np.array([x[1, 0] - x[0, 0], y[1, 0] - y[0, 0], z[1, 0] - z[0, 0]])
else: # pragma: no cover
u = np.array([1.0, 0.0, 0.0])
if x.shape[1] > 1:
v = np.array([x[0, 1] - x[0, 0], y[0, 1] - y[0, 0], z[0, 1] - z[0, 0]])
else: # pragma: no cover
v = np.array([0.0, 1.0, 0.0])
grid_shape = x.shape
for model_actor in scene_actors.values():
mesh = model_actor.custom_object.get_mesh()
target_points = mesh.points
all_indices = self.__find_nearest_neighbors(
isar_points, target_points, grid_origin=grid_origin, u=u, v=v, grid_shape=grid_shape
)
mag_for_color = np.ndarray.flatten(cpos[all_indices])
if mesh.__class__.__name__ != "PolyData": # pragma: no cover
mesh_triangulated = mesh.triangulate()
model_actor.custom_object.mesh = pv.PolyData(mesh_triangulated.points, mesh_triangulated.cells)
else:
model_actor.custom_object.mesh.clear_data()
model_actor.custom_object.mesh[self.rcs_data.data_conversion_function] = mag_for_color
actor += model_actor.custom_object.mesh
all_results_actors = list(self.all_scene_actors["results"].keys())
if "isar_2d" not in all_results_actors:
self.all_scene_actors["results"]["isar_2d"] = {}
index = 0
while f"isar_2d_{index}" in self.all_scene_actors["results"]["isar_2d"]:
index += 1
isar_name = f"isar_2d_{index}"
isar_object = SceneMeshObject()
isar_object.name = isar_name
scalar_dict = dict(color="#000000", title=f"ISAR 2D {index} [{self.rcs_data.data_conversion_function}]")
isar_object.scalar_dict = scalar_dict
isar_object.color_map = color_bar
isar_object.mesh = actor
isar_object.clim = (vmin, vmax)
isar_object.default_clim = (vmin, vmax)
isar_object.plot_type = plot_type.lower()
rcs_mesh = MeshObjectPlot(isar_object, isar_object.get_mesh())
self.all_scene_actors["results"]["isar_2d"][isar_name] = rcs_mesh
[docs]
@pyaedt_function_handler()
@graphics_required
def add_isar_3d(
self, plot_type: str = "iso-surface", color_bar: str = "jet", plane_cut: str = None, plane_offset: float = None
) -> None:
"""Add the ISAR 3D plot to the 3D scene.
Parameters
----------
plot_type : str, default: ``"iso-surface"``
Type of plot to create for the range profile. Options are `"iso-surface"`, `"plane cut"`, `"point cloud"`,
and `"projection"`.
color_bar : str, default: ``"jet"``
Color mapping to apply to the RCS data. It can be a
color (such as ``"blue"`` or ``"green"``) or a colormap
(such as ``"jet"``or ``"viridis"``).
plane_cut : str, default: ``None``
Type of plane cut to create for 3D ISAR. Options are
``"xy"``, ``"xz"``, and ``"yz"``. The ``None`` default
is for other plot types.
plane_offset : float, default: ``None``
Offset value for the plane cut, which is used to
specify the position of the plane in the 3D ISAR
data. The ``None`` default is for other plot types.
"""
data_isar_3d = self.rcs_data.isar_3d
down_range = data_isar_3d["Down-range"].unique()
cross_range_az = data_isar_3d["Cross-range-az"].unique()
cross_range_el = data_isar_3d["Cross-range-el"].unique()
dx = down_range[1] - down_range[0] if len(down_range) > 1 else 0
dy = cross_range_az[1] - cross_range_az[0] if len(cross_range_az) > 1 else 0
dz = cross_range_el[1] - cross_range_el[0] if len(cross_range_el) > 1 else 0
down_range_grid = np.linspace(down_range[0] - dx / 2, down_range[-1] + dx / 2, num=len(down_range) + 1)
cross_range_az_grid = np.linspace(
cross_range_az[0] - dy / 2, cross_range_az[-1] + dy / 2, num=len(cross_range_az) + 1
)
cross_range_el_grid = np.linspace(
cross_range_el[0] - dz / 2, cross_range_el[-1] + dz / 2, num=len(cross_range_el) + 1
)
# The above pivot may need to be adjusted depending on the data layout
# For now, let's try to reshape the data
# instead of modifying the direction of the down_range grid, we just flip the order of the data
# isar 3d. down_range now means "x" axis.
try:
values_3d = data_isar_3d["Data"].to_numpy()
values_3d = values_3d.reshape((len(down_range), len(cross_range_az), len(cross_range_el)))
values_3d = values_3d[::-1, :, :] # reverse down_range to match the original data orientation
except Exception: # pragma: no cover
# fallback: fill with zeros
values_3d = np.zeros((len(down_range), len(cross_range_az), len(cross_range_el)))
all_results_actors = list(self.all_scene_actors["results"].keys())
if "isar_3d" not in all_results_actors:
self.all_scene_actors["results"]["isar_3d"] = {}
index = 0
while f"isar_3d_{index}" in self.all_scene_actors["results"]["isar_3d"]:
index += 1
isar_name = f"isar_3d_{index}"
vmin, vmax = np.nanmin(values_3d), np.nanmax(values_3d)
if plot_type.casefold() != "plane cut":
# Implementation for point cloud, iso-surface, and projection plots
if plot_type.casefold() == "point cloud":
shape = (len(down_range_grid), len(cross_range_az_grid), len(cross_range_el_grid))
spacing = [dx, dy, dz]
origin = (np.min(down_range_grid), np.min(cross_range_az_grid), np.min(cross_range_el_grid))
else:
# iso-surface and projection require the center of the points, not the grid
shape = (len(down_range), len(cross_range_az), len(cross_range_el))
spacing = [dx, dy, dz]
origin = (np.min(down_range), np.min(cross_range_az), np.min(cross_range_el))
else:
# implementation for plane cut plot
if plane_cut is None:
raise ValueError("Please provide a plane cut type.")
if plane_offset is None:
raise ValueError("Please provide a plane offset value.")
if plane_cut.casefold() == "xy":
idx_fixed_range = (np.abs(cross_range_el - plane_offset)).argmin()
values_3d = values_3d[:, :, idx_fixed_range]
shape = (len(down_range_grid), len(cross_range_az_grid), 1)
spacing = [dx, dy, 0]
origin = (np.min(down_range_grid), np.min(cross_range_az_grid), cross_range_el[idx_fixed_range])
elif plane_cut.casefold() == "xz":
idx_fixed_range = (np.abs(cross_range_az - plane_offset)).argmin()
values_3d = values_3d[:, idx_fixed_range, :]
shape = (len(down_range_grid), 1, len(cross_range_el_grid))
spacing = [dx, 0, dz]
origin = (np.min(down_range_grid), cross_range_az[idx_fixed_range], np.min(cross_range_el_grid))
elif plane_cut.casefold() == "yz":
idx_fixed_range = (np.abs(down_range + plane_offset)).argmin() # down_range is negative
values_3d = values_3d[idx_fixed_range, :, :]
shape = (1, len(cross_range_az_grid), len(cross_range_el_grid))
spacing = [0, dy, dz]
origin = (down_range[idx_fixed_range], np.min(cross_range_az_grid), np.min(cross_range_el_grid))
else:
raise ValueError("Invalid plane cut. Choose 'xy', 'xz', or 'yz'.")
if plot_type.casefold() != "projection":
actor = pv.ImageData(dimensions=shape, spacing=spacing, origin=origin)
actor["scalars"] = values_3d.ravel(order="F")
else:
# configuration of this actor will happen in the relevant code block
actor = pv.PolyData()
isar_object = SceneMeshObject()
isar_object.name = isar_name
scalar_dict = dict(color="#FFFFFF", title=f"ISAR 3D {index} [{self.rcs_data.data_conversion_function}]")
isar_object.scalar_dict = scalar_dict
isar_object.color_map = color_bar
if plot_type.casefold() == "point cloud":
isar_object.mesh = actor
isar_object.object_type = SceneMeshObjectType.VOLUME
isar_object.opacity = "linear" if "dB" not in self.rcs_data.data_conversion_function else "geom"
elif plot_type.casefold() == "plane cut":
isar_object.mesh = actor
isar_object.object_type = SceneMeshObjectType.MESH
isar_object.opacity = 1.0
elif plot_type.casefold() == "iso-surface":
contour_values = np.linspace(vmin, vmax, self.num_contours)
contours = actor.contour(isosurfaces=contour_values)
isar_object.mesh = contours
isar_object.object_type = SceneMeshObjectType.MESH
isar_object.opacity = "linear" if "dB" not in self.rcs_data.data_conversion_function else "geom"
else: # Projection
scene_actors = self.all_scene_actors["model"]
if scene_actors is None: # pragma: no cover
return None
# Prepare grid geometry for fast nearest neighbor search
# x, y, z are meshgrid arrays (down_range, cross_range, 0), shape (N, M)
# The grid is in the (x, y) plane, z=0
# Grid origin is the (x[0,0], y[0,0], z[0,0])
x, y, z = np.meshgrid(down_range, cross_range_az, cross_range_el, indexing="ij")
cpos = values_3d.flatten()
isar_points = np.column_stack((x.flatten(), y.flatten(), z.flatten()))
grid_origin = isar_points[0, :]
# Basis vectors: u = (x[1,0,0] - x[0,0,0], y[1,0,0] - y[0,0,0], z[1,0,0] - z[0,0,0])
# v = (x[0,1,0] - x[0,0,0], y[0,1,0] - y[0,0,0], z[0,1,0] - z[0,0,0])
# w = (x[0,0,1] - x[0,0,0], y[0,0,1] - y[0,0,0], z[0,0,1] - z[0,0,0])
if x.shape[0] > 1:
u = np.array([x[1, 0, 0] - x[0, 0, 0], y[1, 0, 0] - y[0, 0, 0], z[1, 0, 0] - z[0, 0, 0]])
else: # pragma: no cover
u = np.array([1.0, 0.0, 0.0])
if x.shape[1] > 1:
v = np.array([x[0, 1, 0] - x[0, 0, 0], y[0, 1, 0] - y[0, 0, 0], z[0, 1, 0] - z[0, 0, 0]])
else: # pragma: no cover
v = np.array([0.0, 1.0, 0.0])
if x.shape[2] > 1:
w = np.array([x[0, 0, 1] - x[0, 0, 0], y[0, 0, 1] - y[0, 0, 0], z[0, 0, 1] - z[0, 0, 0]])
else: # pragma: no cover
w = np.array([0.0, 0.0, 1.0])
grid_shape = x.shape
for model_actor in scene_actors.values():
mesh = model_actor.custom_object.get_mesh()
target_points = mesh.points
all_indices = self.__find_nearest_neighbors(
isar_points, target_points, grid_origin=grid_origin, u=u, v=v, w=w, grid_shape=grid_shape
)
mag_for_color = np.ndarray.flatten(cpos[all_indices])
if mesh.__class__.__name__ != "PolyData": # pragma: no cover
mesh_triangulated = mesh.triangulate()
model_actor.custom_object.mesh = pv.PolyData(mesh_triangulated.points, mesh_triangulated.cells)
else:
model_actor.custom_object.mesh.clear_data()
model_actor.custom_object.mesh[self.rcs_data.data_conversion_function] = mag_for_color
actor += model_actor.custom_object.mesh
isar_object.mesh = actor
isar_object.object_type = SceneMeshObjectType.MESH
isar_object.opacity = 1.0
isar_object.clim = (vmin, vmax)
isar_object.default_clim = (vmin, vmax)
isar_object.plot_type = plot_type.lower()
isar_object.default_mesh = actor
rcs_mesh = MeshObjectPlot(isar_object, isar_object.get_mesh())
self.all_scene_actors["results"]["isar_3d"][isar_name] = rcs_mesh
[docs]
def get_plane_cut_from_3d_isar(self, shape, trace_properties): # pragma: no cover
"""
Extract a 2D plane cut from 3D ISAR data based on the specified plane.
This method computes a slice of the 3D ISAR dataset by selecting the closest available
plane offset in the data. It returns the corresponding sliced data and the new shape
of the 2D array.
Parameters
----------
shape : tuple of int
The shape of the original 3D ISAR data array.
trace_properties : dict
Dictionary containing the following keys:
``"plane_cut"``: str.
Indicates the slicing plane. Must be one of ``"XY-Plane"``, ``"XZ-Plane"``, or ``"YZ-Plane"``.
``"plane_offset"``: float.
The target coordinate value at which to slice the selected plane.
Returns
-------
new_data : pandas.DataFrame
A 2D slice of the ISAR data at the nearest available offset.
new_shape : tuple of int
Shape of the resulting 2D array after slicing. One of the dimensions will be ``1``.
"""
plane_offset = trace_properties["plane_offset"]
if trace_properties["plane_cut"] == "XY-Plane":
slice_range_name = "Cross-range2"
new_shape = (shape[0], shape[1], 1)
elif trace_properties["plane_cut"] == "XZ-Plane":
slice_range_name = "Cross-range1"
new_shape = (shape[0], 1, shape[2])
else:
slice_range_name = "Down-range"
new_shape = (1, shape[1], shape[2])
slice_range = self.rcs_data.raw_data.index.get_level_values(slice_range_name)
idx_fixed_range = (np.abs(slice_range - plane_offset)).argmin()
trace_properties["plane_offset"] = slice_range[idx_fixed_range] # replace with actual value
new_data = self.rcs_data.raw_data.loc[slice_range == trace_properties["plane_offset"]]
return new_data, new_shape
[docs]
@pyaedt_function_handler()
def add_incident_rcs_settings(
self,
theta_span: float,
num_theta: int,
phi_span: float,
num_phi: int,
arrow_color: str = "#ff0000",
line_color: str = "#ff0000",
):
"""Add incident wave arrow setting for RCS scene.
This function visualizes the incident wave arrows for RCS settings.
Parameters
----------
theta_span : float
Incident theta angle in degrees.
num_theta : int
Number of theta points.
phi_span : float
Incident phi angle in degrees.
num_phi : int
Number of phi points.
arrow_color : str, default: ``"#ff0000"``
Color of the arrow. The ``"#ff0000"`` default is red.
line_color : str, default: ``"#ff0000"``
Color of the line. The ``"#ff0000"`` default is red.
"""
self._add_incident_settings(
scene_type="rcs",
theta_span=theta_span,
num_theta=num_theta,
phi_span=phi_span,
num_phi=num_phi,
arrow_color=arrow_color,
line_color=line_color,
)
[docs]
@pyaedt_function_handler()
def add_incident_range_profile_settings(self, arrow_color: str = "#ff0000"):
"""Add incident wave arrow setting for range profile scene.
This function visualizes the incident wave arrows for RCS settings.
Parameters
----------
arrow_color : str, default: ``"#ff0000"``
Color of the arrow. The ``"#ff0000"`` default is red.
"""
self._add_incident_settings(scene_type="range_profile", arrow_color=arrow_color)
[docs]
@pyaedt_function_handler()
def add_incident_waterfall_settings(
self, phi_span: float, num_phi: int, arrow_color: str = "#ff0000", line_color: str = "#ff0000"
):
"""Add incident wave arrow setting for waterfall scene.
This function visualizes the incident wave arrows for waterfall settings.
Parameters
----------
phi_span : float
Incident phi angle in degrees.
num_phi : int
Number of phi points.
arrow_color : str, default: ``"#ff0000"``
Color of the arrow. The ``"#ff0000"`` default is red.
line_color : str, default: ``"#ff0000"``
Color of the line. The ``"#ff0000"`` default is red.
"""
self._add_incident_settings(
scene_type="waterfall", phi_span=phi_span, num_phi=num_phi, arrow_color=arrow_color, line_color=line_color
)
[docs]
@pyaedt_function_handler()
def add_incident_isar_2d_settings(
self, phi_span: float, num_phi: int, arrow_color: str = "#ff0000", line_color: str = "#ff0000"
):
"""Add incident wave arrow setting for ISAR 2D scene.
This function visualizes the incident wave arrows for ISAR 2D settings.
Parameters
----------
phi_span : float
Incident phi angle in degrees.
num_phi : int
Number of phi points.
arrow_color : str, default: ``"#ff0000"``
Color of the arrow. The ``"#ff0000"`` default is red.
line_color : str, default: ``"#ff0000"``
Color of the line. The ``"#ff0000"`` default is red.
"""
self._add_incident_settings(
scene_type="isar_2d", phi_span=phi_span, num_phi=num_phi, arrow_color=arrow_color, line_color=line_color
)
[docs]
@pyaedt_function_handler()
def add_incident_isar_3d_settings(
self,
theta_span: float,
num_theta: int,
phi_span: float,
num_phi: int,
arrow_color: str = "#ff0000",
line_color: str = "#ff0000",
):
"""Add incident wave arrow setting for ISAR 3D scene.
This function visualizes the incident wave arrows for ISAR 3D settings.
Parameters
----------
theta_span : float
Incident theta angle in degrees.
num_theta : int
Number of theta points.
phi_span : float
Incident phi angle in degrees.
num_phi : int
Number of phi points.
arrow_color : str, default: ``"#ff0000"``
Color of the arrow. The ``"#ff0000"`` default is red.
line_color : str, default: ``"#ff0000"``
Color of the line. The ``"#ff0000"`` default is red.
"""
self._add_incident_settings(
scene_type="isar_3d",
theta_span=theta_span,
num_theta=num_theta,
phi_span=phi_span,
num_phi=num_phi,
arrow_color=arrow_color,
line_color=line_color,
)
[docs]
@pyaedt_function_handler()
def clear_scene(self, first_level=None, second_level=None, name=None):
"""
Clear actors from the visualization scene.
This method removes elements from the scene based on the provided hierarchy levels.
By default, it clears all annotations and results. The geometry (model) cannot be cleared using this method.
Parameters
----------
first_level : str, default: ``None``
Top-level key indicating what to clear. Options are
``"annotations"``, ``"model"``, and ``"results"``.
If no option is provided, both annotations and
results are cleared.
second_level : str, default: ``None``
Subcategory within the specified ``first_level``
key. If no value is provided, all data under the ``first_level`` key is cleared.
name : str, default: ``None``
Specific item to remove within the ``second_level`` subcategory. If no value is provided,
all items under the ``second_level`` subcategory
are removed.
Returns
-------
bool
Returns ``True`` successful, ``False`` otherwise.
"""
if not first_level:
self.all_scene_actors["annotations"] = {}
self.all_scene_actors["results"] = {}
elif first_level == "model":
self.__logger.warning("Model can not be cleared. Set 'show_geometry' to False.")
return False
elif first_level in ["annotations", "results"]:
if not second_level:
self.all_scene_actors[first_level] = {}
elif second_level in self.all_scene_actors[first_level].keys(): # pragma: no cover
if not name:
self.all_scene_actor[first_level][second_level] = {}
elif name in self.all_scene_actors[first_level][second_level].keys():
del self.all_scene_actors[first_level][second_level][name]
return True
@pyaedt_function_handler()
def _add_incident_settings(
self,
scene_type="RCS",
theta_span=0.0,
num_theta=101,
phi_span=0.0,
num_phi=101,
arrow_color="#ff0000",
line_color="#ff0000",
):
# Compute parameters
radius_max = max([abs(self.extents[0]), abs(self.extents[1]), abs(self.extents[2]), abs(self.extents[3])])
arrow_length = radius_max * 0.25
if "incident_wave" not in self.all_scene_actors["annotations"]:
self.all_scene_actors["annotations"]["incident_wave"] = {}
# Plot arrows
# if we end up wanting to plot points, we need to keep the whole span.
# Arrows are only plotted at the edges of the domain
theta = np.linspace(0, theta_span, num_theta)
theta -= theta[len(theta) // 2]
theta += 90
theta = np.deg2rad([theta[0], theta[-1]])
phi = np.linspace(0, phi_span, num_phi)
phi -= phi[len(phi) // 2]
phi = np.deg2rad([phi[0], phi[-1]])
corners = ((theta[0], phi[0]), (theta[0], phi[1]), (theta[1], phi[1]), (theta[1], phi[0]))
for i, (t, p) in enumerate(corners):
arrow_direction = -np.array([np.cos(p) * np.sin(t), np.sin(p) * np.sin(t), np.cos(t)])
arrow_start = -arrow_direction * (radius_max + arrow_length)
name = "arrow" + str(i)
arrow_mesh = self._create_arrow(
start=arrow_start, direction=arrow_direction, scale=arrow_length, name=name, color=arrow_color
)
self.all_scene_actors["annotations"]["incident_wave"][name] = arrow_mesh
corner1 = arrow_start
n_t, n_p = corners[(i + 1) % 4]
n_arrow_direction = -np.array([np.cos(n_p) * np.sin(n_t), np.sin(n_p) * np.sin(n_t), np.cos(n_t)])
corner2 = -n_arrow_direction * (radius_max + arrow_length)
name = "arc" + str(i)
arc_mesh = self._create_arc(
pointa=corner1,
pointb=corner2,
center=(0, 0, 0),
resolution=100,
negative=False,
name=name,
color=line_color,
)
self.all_scene_actors["annotations"]["incident_wave"][name] = arc_mesh
return True
@pyaedt_function_handler()
@graphics_required
def _create_arrow(self, start, direction, scale, name, color):
arrow = pv.Arrow(start=start, direction=direction, scale=scale)
arrow_object = SceneMeshObject()
arrow_object.name = name
arrow_object.color = color
arrow_object.line_width = 5
arrow_object.specular = 0.5
arrow_object.lighting = True
arrow_object.smooth_shading = False
arrow_object.mesh = arrow
return MeshObjectPlot(arrow_object, arrow_object.get_mesh())
@pyaedt_function_handler()
@graphics_required
def _create_arc(self, pointa, pointb, center, resolution, negative, name, color):
arc = pv.CircularArc(pointa=pointa, pointb=pointb, center=center, resolution=resolution, negative=negative)
arc_object = SceneMeshObject()
arc_object.name = name
arc_object.color = color
arc_object.line_width = 5
arc_object.specular = 0.5
arc_object.lighting = True
arc_object.smooth_shading = False
arc_object.mesh = arc
return MeshObjectPlot(arc_object, arc_object.get_mesh())
@pyaedt_function_handler()
@graphics_required
def _create_cone(self, center, direction, radius, height, resolution, name, color):
cone = pv.Cone(center=center, direction=direction, radius=radius, height=height, resolution=resolution)
cone_object = SceneMeshObject()
cone_object.name = name
cone_object.color = color
cone_object.line_width = 5
cone_object.specular = 0.5
cone_object.lighting = True
cone_object.smooth_shading = False
cone_object.mesh = cone
return MeshObjectPlot(cone_object, cone_object.get_mesh())
@pyaedt_function_handler()
@graphics_required
def _create_line(self, pointa, pointb, name, color):
line = pv.Line(pointa=pointa, pointb=pointb)
line_object = SceneMeshObject()
line_object.name = name
line_object.color = color
line_object.line_width = 5
line_object.mesh = line
return MeshObjectPlot(line_object, line_object.get_mesh())
@pyaedt_function_handler()
@graphics_required
def __get_pyvista_range_profile_actor(
self,
xpos,
ypos,
plot_data,
cpos,
axis_vec,
plot_type="Line",
data_conversion_function="",
scene_actors=None,
extents=None,
):
"""Get PyVista actor for range profile 3D scene."""
if extents is None: # pragma: no cover
extents = [0, 10, 0, 10, 0, 10]
plot_type_lower = plot_type.casefold()
actor = None
if not plot_type_lower == "projection":
xyz_pos = np.stack((xpos, ypos, plot_data)).T
actor = pv.lines_from_points(xyz_pos)
actor[data_conversion_function] = cpos
if plot_type_lower in ["ribbon", "plane v", "plane h"]:
extents_radius = max(
(extents[0] ** 2 + extents[2] ** 2 + extents[4] ** 2) ** 0.5,
(extents[1] ** 2 + extents[3] ** 2 + extents[5] ** 2) ** 0.5,
)
geo_width = 2 * extents_radius
if plot_type_lower == "plane v":
norm_vect = axis_vec[1]
else:
norm_vect = axis_vec[2]
actor = actor.ribbon(width=geo_width / 2, normal=norm_vect)
elif plot_type_lower == "rotated":
v = axis_vec[0]
v_hat = v / np.linalg.norm(v)
actor.extrude_rotate(rotation_axis=v_hat, capping=True, inplace=True)
elif plot_type_lower == "extruded":
radius_max = max(
(extents[0] ** 2 + extents[2] ** 2 + extents[4] ** 2) ** 0.5,
(extents[1] ** 2 + extents[3] ** 2 + extents[5] ** 2) ** 0.5,
)
width_max = (
(actor.bounds[1] - actor.bounds[0]) ** 2
+ (actor.bounds[3] - actor.bounds[2]) ** 2
+ (actor.bounds[5] - actor.bounds[4]) ** 2
) ** 0.5
center = np.array(actor.center)
center = center - axis_vec[2] * radius_max
plane = pv.Plane(
center=center,
direction=-axis_vec[2],
i_size=width_max,
j_size=width_max,
)
actor.extrude_trim(-axis_vec[2], plane, inplace=True)
elif plot_type_lower == "projection":
if scene_actors is None: # pragma: no cover
return None
actor = pv.PolyData()
for model_actor in scene_actors.values():
mesh = model_actor.custom_object.get_mesh()
xypoints = mesh.points
xpos_ypos = np.column_stack((xpos, ypos, plot_data))
all_indices = self.__find_nearest_neighbors(xpos_ypos, xypoints)
mag_for_color = np.ndarray.flatten(cpos[all_indices])
if not mesh.__class__.__name__ == "PolyData": # pragma: no cover
mesh_triangulated = mesh.triangulate()
model_actor.custom_object.mesh = pv.PolyData(mesh_triangulated.points, mesh_triangulated.cells)
else:
model_actor.custom_object.mesh.clear_data()
model_actor.custom_object.mesh[data_conversion_function] = mag_for_color
actor += model_actor.custom_object.mesh
else: # pragma: no cover
raise ValueError(f"Invalid plot type: {plot_type}.")
return actor
[docs]
@staticmethod
def stretch_data(data, scaling_factor, offset):
"""
Stretches and scales the input data to a specified range.
This method normalizes the input data between its minimum and maximum values and then applies
a linear transformation using the formula: ``scaled_data = (data - min) / (max - min) * m + b``.
The parameters ``m`` and ``b`` control the scaling and shifting of the normalized data.
Parameters
----------
data : numpy.ndarray or pandas.Series
The input data array or series to be stretched.
scaling_factor : float
The scaling factor applied to the normalized data.
offset : float
The offset added to the scaled data after normalization.
Returns
-------
numpy.ndarray or pandas.Series
Transformed data
Examples
--------
>>> data = np.array([1, 2, 3, 4, 5])
>>> stretched_data = stretch_data(data, 2, 1)
>>> print(stretched_data)
[1. 1.5 2. 2.5 3. ]
"""
return (data - data.min()) / (data.max() - data.min()) * scaling_factor + offset
@staticmethod
def __find_nearest_neighbors(orig_points, target_points, grid_origin=None, u=None, v=None, w=None, grid_shape=None):
"""
Fast nearest neighbor search for points on a 2D or 3D grid in 3D, using grid geometry.
If grid_origin, u, v, (optionally w), and grid_shape are provided, uses projection-based search.
Otherwise, falls back to brute-force search.
Parameters
----------
orig_points : numpy.ndarray
Array of shape (n, 3) representing the original grid points (x, y, z).
target_points : numpy.ndarray
Array of shape (m, 3) representing the target points (x, y, z).
grid_origin : numpy.ndarray, default: ``None``
3D origin of the grid (shape (3,)).
u : numpy.ndarray, default: ``None``
3D basis vector for grid axis 0 (shape (3,)).
v : numpy.ndarray, default: ``None``
3D basis vector for grid axis 1 (shape (3,)).
w : numpy.ndarray, default: ``None``
3D basis vector for grid axis 2 (shape (3,)), for 3D grids.
grid_shape : tuple, default: ``None``
(n_i, n_j) or (n_i, n_j, n_k) shape of the grid.
Returns
-------
numpy.ndarray
Array of shape (m,) containing the indices of the
nearest neighbors in the ``orig_points`` parameter
for each target point.
"""
if grid_origin is not None and u is not None and v is not None and grid_shape is not None:
if w is not None and len(grid_shape) == 3:
# 3D grid
u_ = np.column_stack((u, v, w)) # 3x3
u_pinv = np.linalg.pinv(u_) # 3x3
n_i, n_j, n_k = grid_shape
indices = []
for p in target_points:
ijk = u_pinv @ (p - grid_origin)
i, j, k = np.round(ijk).astype(int)
i = i % n_i
j = j % n_j
k = k % n_k
idx = i * (n_j * n_k) + j * n_k + k # flatten index
indices.append(idx)
return np.array(indices)
else:
# 2D grid (original logic)
n = np.cross(u, v)
n = n / np.linalg.norm(n)
u_ = np.column_stack((u, v)) # 3x2
u_pinv = np.linalg.pinv(u_) # 2x3
n_i, n_j = grid_shape
indices = []
for p in target_points:
# Project onto grid plane
p_proj = p - np.dot(p - grid_origin, n) * n
# Get (i, j) coordinates
ij = u_pinv @ (p_proj - grid_origin)
i, j = np.round(ij).astype(int)
i = i % n_i
j = j % n_j
idx = i * n_j + j # flatten index
indices.append(idx)
return np.array(indices)
else: # pragma: no cover
# Fallback: brute-force search
distances = ((orig_points[:, np.newaxis] - target_points) ** 2).sum(axis=2)
return np.argmin(distances, axis=0)
@staticmethod
def __add_mesh(mesh_object, plotter, mesh_type="results"):
"""Add a mesh to the plotter with additional options."""
options = {}
if getattr(mesh_object, "custom_object", None):
if mesh_type == "model":
options = mesh_object.custom_object.get_model_options()
elif mesh_type == "annotations":
options = mesh_object.custom_object.get_annotation_options()
else:
options = mesh_object.custom_object.get_result_options()
plotter.plot(mesh_object.custom_object.get_mesh(), **options)
@pyaedt_function_handler()
def __get_model_extent(self):
"""
Calculate the 3D extent of the model by evaluating the bounding box dimensions of each mesh object in the scene.
This method retrieves the maximum and minimum coordinates in the x, y, and z
directions for all mesh objects stored under the "model" key in `self.all_scene_actors`.
The bounding box of each mesh is assessed, and the overall bounds for the entire
model are determined by taking the min/max values from these individual bounding boxes.
"""
x_max, x_min, y_max, y_min, z_max, z_min = [], [], [], [], [], []
if len(self.all_scene_actors["model"]) == 0:
x_max = [1]
x_min = [-1]
y_max = [1]
y_min = [-1]
z_max = [1]
z_min = [-1]
for each in self.all_scene_actors["model"].values():
b = each.custom_object.get_mesh().bounds
x_max.append(b[1])
x_min.append(b[0])
y_max.append(b[3])
y_min.append(b[2])
z_max.append(b[5])
z_min.append(b[4])
self.__x_max, self.__x_min = max(x_max), min(x_min)
self.__y_max, self.__y_min = max(y_max), min(y_min)
self.__z_max, self.__z_min = max(z_max), min(z_min)
@pyaedt_function_handler()
@graphics_required
def __get_geometry(self):
"""Get 3D meshes."""
model_info = self.model_info
obj_meshes = {}
first_value = next(iter(model_info.values()))
self.__model_units = first_value[3]
for object_in in model_info.values():
relative_cad_path, color, opacity, units = object_in
relative_path = Path(relative_cad_path)
name = relative_path.stem
relative_path = Path("geometry") / relative_path
cad_path = Path(self.rcs_data.output_dir) / relative_path
try:
conv = AEDT_UNITS["Length"][units]
except Exception: # pragma: no cover
conv = 1
if cad_path.exists():
mesh = pv.read(str(cad_path))
mesh.scale(conv)
else: # pragma: no cover
self.__logger.warning(f"{cad_path} does not exist.")
return False
color_cad = [i / 255 for i in color]
model_object = SceneMeshObject()
model_object.color = color_cad
model_object.opacity = opacity
model_object.name = name
model_object.mesh = mesh
mesh_object = MeshObjectPlot(model_object, model_object.get_mesh())
obj_meshes[model_object.name] = mesh_object
return obj_meshes
[docs]
@staticmethod
def rotate_point(x1, y1, z1, rotation):
"""
Rotate a set of 3D points by the specified azimuth, elevation, and twist angles.
Parameters
----------
x1 : np.ndarray
Array of x-coordinates of the points to be rotated.
y1 : np.ndarray
Array of y-coordinates of the points to be rotated.
z1 : np.ndarray
Array of z-coordinates of the points to be rotated.
rotation : dict
Dictionary containing rotation angles in degrees with the following keys:
- "azimuth": Rotation angle around the z-axis.
- "elevation": Rotation angle around the y-axis.
- "twist": Rotation angle around the x-axis.
Returns
-------
x2 : np.ndarray
Array of rotated x-coordinates.
y2 : np.ndarray
Array of rotated y-coordinates.
z2 : np.ndarray
Array of rotated z-coordinates.
Notes
-----
The rotation is performed in the following order:
1. Azimuth (z-axis)
2. Elevation (y-axis)
3. Twist (x-axis)
"""
az_rad = np.radians(rotation["azimuth"])
el_rad = np.radians(rotation["elevation"])
tw_rad = np.radians(rotation["twist"])
# Rotation around z-axis (azimuth direction)
maz = np.array([[np.cos(az_rad), -np.sin(az_rad), 0], [np.sin(az_rad), np.cos(az_rad), 0], [0, 0, 1]])
# Rotation around y-axis (elevation direction)
mel = np.array([[np.cos(el_rad), 0, np.sin(el_rad)], [0, 1, 0], [-np.sin(el_rad), 0, np.cos(el_rad)]])
# Rotation around x-axis (twist direction)
mtw = np.array([[1, 0, 0], [0, np.cos(tw_rad), -np.sin(tw_rad)], [0, np.sin(tw_rad), np.cos(tw_rad)]])
# Combine the rotations
r = np.dot(mtw, np.dot(mel, maz))
# Twist is applied to rotated local coordinate. This corrects to
# rotating along global coordinate.
r = uvecs2rot(r[0, :], r[1, :])
# apply the rotations
points = np.column_stack((np.ravel(x1), np.ravel(y1), np.ravel(z1)))
rotated_points = np.dot(r, points.T).T
x2 = rotated_points[:, 0].reshape(x1.shape)
y2 = rotated_points[:, 1].reshape(y1.shape)
z2 = rotated_points[:, 2].reshape(z1.shape)
return x2, y2, z2
def uvecs2rot(u, v):
"""
Obtain roll-pitch-yaw angles from two unit vectors.
It obtains the angles of rotation in term of its RPY (roll, pitch, yaw)
from the two unit vectors introduced as parameter along with their
selector
Roll-Pitch-Yaw convention follows Savant convention for defining
coordinate systems: Start with local coordinate system aligned
with global coordinate system. Then perform left-handed rotations
of the local coordinate system ABOUT ITS OWN CURRENT AXES in the
following order: Roll (xlcl, first), Pitch (ylcl, second),
Yaw (zlcl, third and last).
Args:
u (np.ndarray): First input vector. Modified in-place to be unit length.
v (np.ndarray): Second input vector. Modified in-place to be unit length.
Returns
-------
np.ndarray: A 3x3 rotation matrix with `u`, `v`, and `w` as columns.
Raises
------
ValueError: If `u` or `v` is a zero vector.
"""
u /= np.linalg.norm(u)
v /= np.linalg.norm(v)
w = np.cross(u, v)
return np.column_stack((u, v, w))
class SceneMeshObjectType(Enum):
MESH = 1
VOLUME = 2
class SceneMeshObject:
"""
A class representing a custom 3D mesh object with visualization properties.
This class defines a 3D mesh object with customizable properties.
It provides methods to retrieve the mesh, its associated rendering options, and annotation properties for
visualization in PyVista.
"""
@graphics_required
def __init__(self):
# Public
self.name = "CustomObject"
self.opacity = 1.0
self.color = None
self.color_map = "jet"
self.line_width = 1.0
self.specular = 0.0
self.lighting = False
self.smooth_shading = True
self.edge_color = None
self.show_edges = True
self.scalar_dict = dict(color="#000000", title="Dummy")
self.show = True
self.object_type = SceneMeshObjectType.MESH
self.clim = None
# Private
self.__mesh = pv.Cube()
self.__original_points = self.mesh.points.copy()
self.__z_offset = 0.0
self.__scale_factor = 1.0
self.__default_clim = None
self.__plot_type = None
self.__default_mesh = self.mesh
@property
def mesh(self):
"""Get the mesh object."""
return self.__mesh
@mesh.setter
def mesh(self, val):
self.__mesh = val
self.__original_points = val.points.copy()
if self.__default_mesh is None: # pragma: no cover
self.__default_mesh = val
@property
def z_offset(self):
"""Offset in the Z direction."""
return self.__z_offset
@z_offset.setter
def z_offset(self, val):
translation_distance = val
# Calculate the new points by applying the translation to the original points
new_points = self.__original_points.copy()
new_points[:, 2] += translation_distance # Apply Z translation
# Update the mesh with the new points
self.__mesh.points = new_points
self.__z_offset = val
@property
def scale_factor(self):
"""Get the current scale factor."""
return self.__scale_factor
@scale_factor.setter
def scale_factor(self, val):
"""Set a new scale factor and update the mesh accordingly."""
# Calculate the center of the mesh for scaling
center = self.__mesh.points.mean(axis=0) # Center of the original mesh
# Calculate the new points by scaling relative to the original points and the mesh center
new_points = center + (self.__original_points - center) * float(val)
self.__mesh.points = new_points
# Update the scale factor
self.__scale_factor = val
@property
def default_clim(self):
"""Default minimum and maximum field values."""
return self.__default_clim
@default_clim.setter
def default_clim(self, value):
self.__default_clim = value
@property
def default_mesh(self):
"""Original mesh."""
return self.__default_mesh
@default_mesh.setter
def default_mesh(self, value):
self.__default_mesh = value
@property
def plot_type(self):
"""Plot type."""
return self.__plot_type
@plot_type.setter
def plot_type(self, value):
self.__plot_type = value
def reset_scene(self):
"""Reset the mesh to its original position and size."""
self.mesh.points = self.__original_points.copy() # Restore the original points
self.__z_offset = 0.0 # Reset the Z-offset
self.__scale_factor = 1.0 # Reset the scale factor
def get_mesh(self):
"""Retrieve the mesh object.
Returns
-------
pyvista.PolyData or pyvista.UnstructuredGrid
Mesh object representing the 3D geometry.
"""
return self.mesh
def get_model_options(self):
"""Retrieve the visualization options for the mesh.
Returns
-------
dict
A dictionary with the color and opacity settings for rendering the model.
"""
return {"color": self.color, "opacity": self.opacity}
def get_annotation_options(self):
"""Retrieve the annotation options for the mesh.
Returns
-------
dict
A dictionary with the color and line width settings for annotating the model.
"""
return {
"color": self.color,
"line_width": self.line_width,
"specular": self.specular,
"lighting": self.lighting,
"smooth_shading": self.smooth_shading,
"opacity": self.opacity,
"show_edges": self.show_edges,
"edge_color": self.edge_color,
}
def get_result_options(self):
"""Retrieve the result options for the mesh.
Returns
-------
dict
A dictionary with the settings for results the model.
"""
options = {
"opacity": self.opacity,
"cmap": self.color_map,
"scalar_bar_args": self.scalar_dict,
"clim": self.clim,
}
if self.object_type != SceneMeshObjectType.VOLUME:
options["line_width"] = self.line_width
if self.color:
options["color"] = self.color
return options