# -*- coding: utf-8 -*-
"""
==========
Radar Cube
==========
Radar cubes represent the raw image create after the base-band processing of radar samples.
"""
from __future__ import annotations
from typing import Literal, Type
import matplotlib.pyplot as plt
import numpy as np
from h5py import Group
from scipy.constants import speed_of_light
from hermespy.core import Executable, HDFSerializable
__author__ = "Jan Adler"
__copyright__ = "Copyright 2023, Barkhausen Institut gGmbH"
__credits__ = ["Jan Adler"]
__license__ = "AGPLv3"
__version__ = "1.2.0"
__maintainer__ = "Jan Adler"
__email__ = "jan.adler@barkhauseninstitut.org"
__status__ = "Prototype"
[docs]
class RadarCube(HDFSerializable):
"""A representation of raw radar image samples."""
__data: np.ndarray
__angle_bins: np.ndarray
__doppler_bins: np.ndarray
__range_bins: np.ndarray
__carrier_frequency: float
def __init__(
self,
data: np.ndarray,
angle_bins: np.ndarray | None = None,
doppler_bins: np.ndarray | None = None,
range_bins: np.ndarray | None = None,
carrier_frequency: float = 0.0,
) -> None:
"""
Args:
data (np.ndarray):
Raw radar cube data.
Three-dimensional real-valued numpy tensor :math:`\\mathbb{R}^{A \\times B \\times C}`, where
:math:`A` denotes the number of discrete angle of arrival bins,
:math:`B` denotes the number of discrete doppler frequency bins,
and :math:`C` denotes the number of discrete range bins.
angle_bins (np.ndarray):
Numpy matrix specifying the represented discrete angle of arrival bins.
Must be of dimension :math:`\\mathbb{R}^{A \\times 2}`,
the second dimension denoting azimuth and zenith of arrival in radians, respectively.
doppler_bins (np.ndarray):
Numpy vector specifying the represented discrete doppler frequency shift bins in Hz.
Must be of dimension :math:`\\mathbb{R}^{B}`.
range_bins (np.ndarray):
Numpy vector specifying the represented discrete range bins in :math:`\\mathrm{m}`.
Must be of dimension :math:`\\mathbb{R}^{C}`.
carrier_frequency (float, optional):
Central carrier frequency of the radar in Hz.
Zero by default.
Raises:
ValueError:
If the argument numpy arrays have unexpected dimensions or if their dimensions don't match.
"""
if data.ndim != 3:
raise ValueError(
f"Cube data must be a three-dimensional numpy tensor (has {data.ndim} dimenions)"
)
# Infer angle bins
if angle_bins is None:
if data.shape[0] == 1:
angle_bins = np.array([[0, 0]], dtype=np.float_)
else:
raise ValueError("Can't infer angle bins from data cube")
# Infer velocity bins
if doppler_bins is None:
if data.shape[1] == 1:
doppler_bins = np.array([0], dtype=np.float_)
else:
raise ValueError("Can't infer velocity bins from data cube")
# Infer range bins
range_bins = np.arange(data.shape[2], dtype=np.float_) if range_bins is None else range_bins
if data.shape[0] != len(angle_bins):
raise ValueError("Data cube angle dimension does not match angle bins")
if data.shape[1] != len(doppler_bins):
raise ValueError("Data cube velocity dimension does not match doppler bins")
if data.shape[2] != len(range_bins):
raise ValueError("Data cube range dimension does not match range bins")
if carrier_frequency < 0:
raise ValueError(f"Carrier frequency must be non-negtative (not {carrier_frequency})")
self.__data = data
self.__angle_bins = angle_bins
self.__doppler_bins = doppler_bins
self.__range_bins = range_bins
self.__carrier_frequency = carrier_frequency
@property
def data(self) -> np.ndarray:
"""Raw radar cube data.
Three-dimensional real-valued numpy tensor :math:`\\mathbb{R}^{A \\times B \\times C}`, where
:math:`A` denotes the number of discrete angle of arrival bins,
:math:`B` denotes the number of discrete doppler frequency bins,
and :math:`C` denotes the number of discrete range bins.
Returns: Radar cube numpy tensor.
"""
return self.__data
@property
def angle_bins(self) -> np.ndarray:
"""Discrete angle estimation bins.
Returns:
Numpy matrix of dimension :math:`\\mathbb{R}^{A \\times 2}`,
the second dimension denoting azimuth and zenith of arrival in radians, respectively.
"""
return self.__angle_bins
@property
def doppler_bins(self) -> np.ndarray:
"""Discrete doppler shift estimation bins.
Returns:
Numpy vector specifying the represented discrete doppler frequency bins in Hz.
"""
return self.__doppler_bins
@property
def velocity_bins(self) -> np.ndarray:
"""Discrete doppler estimation bins.
Returns:
Numpy vector specifying the represented discrete doppler velocity bins in :math:`\\mathrm{m/s}`.
Raises:
RuntimeError: If the carrier frequency is not specified.
"""
if self.__carrier_frequency <= 0.0:
raise RuntimeError("Carrier frequency not specified")
return self.__doppler_bins * speed_of_light / self.__carrier_frequency
@property
def range_bins(self) -> np.ndarray:
"""Discrete range estimation bins.
Returns:
Numpy vector specifying the represented discrete range bins in :math:`\\mathrm{m}`.
"""
return self.__range_bins
[docs]
def plot_range(
self,
title: str | None = None,
axes: plt.Axes | None = None,
scale: Literal["lin", "log"] = "lin",
) -> plt.FigureBase:
"""Visualize the cube's range data.
Args:
title (str, optional):
Plot title.
Returns:
plt.Figure:
"""
title = "Radar Range Profile" if title is None else title
# Collapse the cube into the range-dimension
range_profile = np.sum(self.data, axis=(0, 1), keepdims=False)
figure: plt.FigureBase
if axes is None:
with Executable.style_context():
figure, axes = plt.subplots()
figure.suptitle(title)
else:
figure = axes.get_figure()
axes.set_xlabel("Range [m]")
axes.set_ylabel("Power")
if scale == "lin":
axes.plot(self.range_bins, range_profile)
elif scale == "log":
axes.semilogy(self.range_bins, range_profile)
else:
raise ValueError(f"Unsupported plotting scale option '{scale}'")
return figure
[docs]
def plot_range_velocity(
self,
title: str | None = None,
interpolate: bool = True,
scale: Literal["frequency", "velocity"] | None = None,
) -> plt.Figure:
"""Visualize the cube's range-velocity profile.
Args:
title (str, optional):
Plot title.
interpolate (bool, optional):
Interpolate the axis for a square profile plot.
Enabled by default.
scale (Literal['frequency', 'velocity'], optional):
Plot the velocity axis in frequency (Hz) or velocity units (m/s).
If not specified, plotting in velocity is preferred, if the carrier frequency is known.
Returns:
plt.Figure:
"""
title = "Radar Range-Doppler Profile" if title is None else title
if scale is None:
_scale = "velocity" if self.__carrier_frequency > 0 else "frequency"
else:
_scale = scale
# Compute velocity axis bins depending on the scale
velocity_axis = self.velocity_bins if _scale == "velocity" else self.doppler_bins
# Collapse the cube into the range-dimension
range_velocity_profile = np.sum(self.data, axis=0, keepdims=False)
with Executable.style_context():
figure, axes = plt.subplots()
figure.suptitle(title)
axes.set_xlabel("Range [m]")
axes.set_ylabel("Doppler [Hz]" if _scale == "frequency" else "Velocity [m/s]")
axes.pcolormesh(self.range_bins, velocity_axis, range_velocity_profile, shading="auto")
return figure
[docs]
def normalize_power(self) -> None:
"""Normalize the represented power indicators to unit maximum."""
self.__data = self.__data / self.__data.max()
@classmethod
def from_HDF(cls: Type[RadarCube], group: Group) -> RadarCube:
data = np.array(group["data"])
angle_bins = np.array(group["angle_bins"], dtype=np.float_)
doppler_bins = np.array(group["doppler_bins"], dtype=np.float_)
range_bins = np.array(group["range_bins"], dtype=np.float_)
carrier_frequency = group.attrs.get("carrier_frequency", 0.0)
return cls(
data=data,
angle_bins=angle_bins,
doppler_bins=doppler_bins,
range_bins=range_bins,
carrier_frequency=carrier_frequency,
)
def to_HDF(self, group: Group) -> None:
self._write_dataset(group, "data", self.data)
self._write_dataset(group, "angle_bins", self.angle_bins)
self._write_dataset(group, "doppler_bins", self.doppler_bins)
self._write_dataset(group, "range_bins", self.range_bins)
group.attrs["carrier_frequency"] = self.__carrier_frequency