# -*- coding: utf-8 -*-
from __future__ import annotations
from fractions import Fraction
import numpy as np
from scipy.constants import speed_of_light, pi
from scipy.fft import fft2
from scipy import signal
from hermespy.core import Signal, Serializable
from .radar import RadarWaveform
__author__ = "Jan Adler"
__copyright__ = "Copyright 2024, Barkhausen Institut gGmbH"
__credits__ = ["Jan Adler"]
__license__ = "AGPLv3"
__version__ = "1.3.0"
__maintainer__ = "Jan Adler"
__email__ = "jan.adler@barkhauseninstitut.org"
__status__ = "Prototype"
[docs]
class FMCW(RadarWaveform, Serializable):
"""
Frequency Modulated Continuous Waveform Radar Sensing with stretch processing.
This class generates a frame consisting of a sequence of unmodulated chirps. They are used for radar detection with
stretch processing, i.e., mixing the received signal with the transmitted sequence, (under)sampling and applying an
FFT.
S
A minimal example configuring an :class:`FMCW` radar waveform illuminating a single target
within the context of a :class:`Simulation<hermespy.simulation.simulation.Simulation>` would look like this:
.. literalinclude:: ../../docssource/scripts/examples/radar_fmcw_FMCW.py
:language: python
:linenos:
:lines: 03-23
"""
yaml_tag = "Radar-FMCW"
__DERIVE_SAMPLING_RATE: float = 0.0
"""Magic number at which FMCW waveforms will automatically derive the sampling rates."""
__num_chirps: int # Number of chirps per radar frame
__bandwidth: float # Sweep bandwidth of the chirp in Hz
__sampling_rate: float # simulation sampling rate of the baseband signal in Hz
__chirp_duration: float # chirp duration in seconds
__pulse_rep_interval: float # pulse repetition interval in seconds
__adc_sampling_rate: float # sampling rate of ADC after mixing
def __init__(
self,
num_chirps: int = 10,
bandwidth: float = 0.1e9,
chirp_duration: float = 1.5e-6,
pulse_rep_interval: float = 1.5e-6,
sampling_rate: float | None = None,
adc_sampling_rate: float | None = None,
) -> None:
"""
Args:
num_chirps (float, optional):
Number of dedicated chirps within a single radar frame.
:math:`10` by default.
bandwidth (float, optional):
Sweep bandwidth of every chirp in Hz.
:math:`0.1~\\mathrm{GHz}` by default.
chirp_duration (float, optional):
Duration of every chirp in seconds.
:math:`1.5~\\mathrm{\\mu s}` by default.
pulse_rep_interval (float, optional):
Repetition interval of the individual chirps in seconds.
:math:`1.5~\\mathrm{\\mu s}` by default.
sampling_rate (float, optional):
Sampling rate of the baseband signal in Hz.
If not specified, the sampling rate will be equal to the bandwidth.
adc_sampling_rate (float, optional):
Sampling rate of the analog-digital conversion in Hz.
If not specified, the adc sampling rate will be equal to the bandwidth.
"""
# Initialize base class
RadarWaveform.__init__(self)
# Initialize attributes
self.num_chirps = num_chirps
self.bandwidth = bandwidth
self.chirp_duration = chirp_duration
self.pulse_rep_interval = pulse_rep_interval
self.sampling_rate = FMCW.__DERIVE_SAMPLING_RATE if sampling_rate is None else sampling_rate
self.adc_sampling_rate = (
FMCW.__DERIVE_SAMPLING_RATE if adc_sampling_rate is None else adc_sampling_rate
)
[docs]
def ping(self) -> Signal:
return Signal.Create(self.__frame_prototype(), self.sampling_rate)
[docs]
def estimate(self, input_signal: Signal) -> np.ndarray:
num_pulse_samples = int(self.pulse_rep_interval * self.sampling_rate)
num_frame_samples = self.num_chirps * num_pulse_samples
resampled_input_signal = input_signal.resample(self.sampling_rate)
input_samples = (
resampled_input_signal[0, :num_frame_samples]
if resampled_input_signal.num_samples >= num_frame_samples
else np.append(
resampled_input_signal[0, :],
np.zeros(num_frame_samples - resampled_input_signal.num_samples),
)
)
chirp_stack = np.reshape(input_samples, (-1, num_pulse_samples))
chirp_prototype = self.__chirp_prototype()
chirp_stack = chirp_stack[:, : len(chirp_prototype)]
baseband_samples = chirp_stack.conj() * chirp_prototype
if self.adc_sampling_rate < self.sampling_rate:
downsampling_rate = Fraction(
self.adc_sampling_rate / self.sampling_rate
).limit_denominator(1000)
baseband_samples = signal.resample_poly(
baseband_samples,
up=downsampling_rate.numerator,
down=downsampling_rate.denominator,
axis=1,
)
transform = fft2(baseband_samples)
# fft shift the Doppler components
transform = np.fft.ifftshift(transform, axes=0)
return np.abs(transform)
@property
def max_range(self) -> float:
max_range = self.adc_sampling_rate * speed_of_light / (2 * self.slope)
return max_range
@property
def range_resolution(self) -> float:
return speed_of_light / (2 * self.bandwidth)
@property
def max_relative_doppler(self) -> float:
# The maximum velocity is the wavelength divided by four times the pulse repetition interval
max_doppler = 1 / (4 * self.pulse_rep_interval)
return max_doppler
@property
def relative_doppler_resolution(self) -> float:
# The doppler resolution is the inverse of twice the frame duration
resolution = 1 / (2 * self.frame_duration)
return resolution
@property
def relative_doppler_bins(self) -> np.ndarray:
return (
np.arange(self.num_chirps) * self.relative_doppler_resolution
- self.max_relative_doppler
)
@property
def frame_duration(self) -> float:
return self.pulse_rep_interval * self.num_chirps
@property
def num_chirps(self) -> int:
"""Number of chirps per transmitted radar frame.
Changing the number of chirps per frame will result in a different radar frame length:
.. plot:: scripts/plots/radar_fmcw_num_chirps.py
Raises:
ValueError: If the number of chirps is smaller than one.
"""
return self.__num_chirps
@num_chirps.setter
def num_chirps(self, value: int) -> None:
if value < 1:
raise ValueError("Number of chirps must be greater or equal to one")
self.__num_chirps = int(value)
@property
def bandwidth(self) -> float:
"""Bandwidth swept during each chirp.
.. plot:: scripts/plots/radar_fmcw_bandwidth.py
Returns:
float: Sweep bandwidth in Hz.
Raises:
ValueError: If bandwidth is smaller or equal to zero.
"""
return self.__bandwidth
@bandwidth.setter
def bandwidth(self, value: float) -> None:
if value <= 0.0:
raise ValueError("Bandwidth must be greater than zero")
self.__bandwidth = value
@property
def chirp_duration(self) -> float:
"""Duration of a single chirp within the FMCW frame.
In combination with :meth:`pulse_rep_interval<.pulse_rep_interval>` the chirp duration
determines the guard interval between two consecutive chirps:
.. plot:: scripts/plots/radar_fmcw_chirp_duration.py
Raises:
ValueErorr: For durations smaller or equal to zero.
"""
return self.__chirp_duration
@chirp_duration.setter
def chirp_duration(self, value: float) -> None:
if value <= 0.0:
raise ValueError("FMCW chirp duration must be greater than zero")
self.__chirp_duration = value
@property
def adc_sampling_rate(self) -> float:
"""Sampling rate at the ADC in Hz.
Raises:
ValueError: If sampling rate is smaller or equal to zero.
"""
# If the sampling rate is not specified, it will be derived from the bandwidth
if self.__adc_sampling_rate == FMCW.__DERIVE_SAMPLING_RATE:
return self.bandwidth
return self.__adc_sampling_rate
@adc_sampling_rate.setter
def adc_sampling_rate(self, value: float) -> None:
if value < 0.0:
raise ValueError("ADC Sampling rate must be non-negative")
self.__adc_sampling_rate = value
@property
def sampling_rate(self) -> float:
# If the sampling rate is not specified, it will be derived from the bandwidth
if self.__sampling_rate == FMCW.__DERIVE_SAMPLING_RATE:
return self.__bandwidth
return self.__sampling_rate
@sampling_rate.setter
def sampling_rate(self, value: float) -> None:
if value < 0.0:
raise ValueError("Sampling rate must be non-negative")
self.__sampling_rate = value
@property
def pulse_rep_interval(self) -> float:
"""Pulse repetition interval in seconds.
The pulse repetition interval determines the overall frame duration.
In combination with the :meth:`chirp_duration<.chirp_duration>` the pulse repetition interval
it determines the guard interval between two consecutive chirps:
.. plot:: scripts/plots/radar_fmcw_pulse_rep_interval.py
Raises:
ValueError: If interval is smaller or equal to zero.
"""
return self.__pulse_rep_interval
@pulse_rep_interval.setter
def pulse_rep_interval(self, value: float) -> None:
if value <= 0.0:
raise ValueError("Pulse repetition interval must be greater than zero")
self.__pulse_rep_interval = value
@property
def slope(self) -> float:
"""Slope of the bandwidth sweep.
Returns:
float: Slope in Hz / s.
Raises:
ValueError: If slope is smaller or equal to zero.
"""
return self.bandwidth / self.chirp_duration
@property
def energy(self) -> float:
"""Energy of an FMCW radar pulse in :math:`\\mathrm{Wh}`."""
return self.num_chirps * int(self.chirp_duration * self.sampling_rate)
@property
def power(self) -> float:
"""Power of an FMCW radar pulse in :math:`\\mathrm{W}`."""
return 1.0
def __chirp_prototype(self) -> np.ndarray:
"""Prototype function to generate a single chirp.
Raises:
RuntimeError: If the sampling rate is smaller than the chirp bandwidth.
"""
# Validate that there's no aliasing during chirp generation
if self.sampling_rate < self.bandwidth:
raise RuntimeError(
f"Sampling rate may not be smaller than chirp bandwidth ({self.sampling_rate} < {self.bandwidth})"
)
num_samples = int(self.chirp_duration * self.sampling_rate)
timestamps = np.arange(num_samples) / self.sampling_rate
# baseband chirp between -B/2 and B/2
chirp = np.exp(1j * pi * (-self.bandwidth * timestamps + self.slope * timestamps**2))
return chirp
def __pulse_prototype(self) -> np.ndarray:
"""Prototype function to generate a single pulse including chirp and guard interval.
Raises:
RuntimeError: If the pulse repetition interval is smaller than the chirp duration.
"""
num_zero_samples = int((self.pulse_rep_interval - self.chirp_duration) * self.sampling_rate)
if num_zero_samples < 0:
raise RuntimeError(
"Pulse repetition interval cannot be less than chirp duration in FMCW radar"
)
pulse = np.concatenate((self.__chirp_prototype(), np.zeros(num_zero_samples)))
return pulse
def __frame_prototype(self) -> np.ndarray:
"""Prototype function to generate a single radar frame."""
frame = np.tile(self.__pulse_prototype(), self.num_chirps)
return frame