### ===================================================================================================================
### Frequency Domain Data Class
### ===================================================================================================================
# Copyright ©2025 Haskoning Nederland B.V.
### ===================================================================================================================
### 1. Import modules
### ===================================================================================================================
# General imports
from dataclasses import dataclass, field
from typing import Union, List, Optional, Dict, Tuple
from pathlib import Path
import numpy as np
import warnings
# References for functions and classes in the haskoning_atr_tools package
from haskoning_atr_tools.signal_processing_tool.utils import plot_function, bar_plot_function, \
get_frequency_ban_level_units
from haskoning_atr_tools.signal_processing_tool.frequency_domain.tools import identify_significant_amplitudes, \
time_domain_data_from_frequency_domain, find_max_amplitude, power_density_form_amplitude_spectrum, rms_from_psd, \
octave_form_frequency_domain_data
### ===================================================================================================================
### 2. FrequencyDomainData class
### ===================================================================================================================
[docs]
@dataclass
class FrequencyDomainData:
"""
The FrequencyDomainData class represents individual data sets of frequency spectrum quantities.
Input:
- parent (FrequencyDomainCollection): The parent collection that this data belongs to.
- name (str): The name of the frequency spectrum data.
- amplitude_type (str): The type of the frequency spectrum data. Example: velocity, acceleration, pressure.
Default value 'Unknown'.
- amplitudes (list of floats): A list of amplitude values for the frequency spectrum data.
- amplitude_units (str): The units of the amplitude values. Defaults value 'Unknown'.
- frequencies (list of floats): A list of frequencies for the frequency spectrum data.
- frequency_units (str): The units of the frequencies. Defaults value 'Unknown'.
- phase_angles (list of floats): A list of phase angles for the frequency spectrum data.
- phase_angle_units (str): The units of the phase angles. Defaults to "Unknown".
- power_density (list of floats): A list of power density values for the frequency spectrum data.
- power_units (str): The units of the power density values. Default value 'Unknown'.
- energy_density (list of floats): A list of energy density values for the frequency spectrum data.
- energy_units (str): The units of the energy density values. Default value 'Unknown'.
- max_amplitude (float): The maximum amplitude value in the frequency spectrum data.
- freq_at_max_amplitude (float): The frequency at which the maximum amplitude occurs.
- freq_increment (float, optional): The frequency increment between data points.
- significant_amplitudes (list of tuples with 3 floats): A list of significant amplitudes with their
corresponding frequencies and phases.
- window_used (str): The window function used for the frequency domain transformation. Default value None, no
window function applied.
- signal_duration (float): Optional input for the duration of the signal, in [s]. May be required for certain
functions applied on the FrequencyDomainData.
- rms_spectrum (list of floats): A list of RMS values for the frequency spectrum data.
- total_rms (float): The total RMS value of the frequency spectrum data.
"""
warnings.warn(
"WARNING:The Signal Processing Tool is currently under active development. Functionality may change in future "
"updates, and formal validation is still pending. Please verify your results carefully before using them in "
"your applications.")
parent: 'FrequencyDomainCollection'
name: str
amplitude_type: str = 'Unknown'
amplitudes: List[float] = field(default_factory=list)
amplitude_units: str = 'Unknown'
frequencies: List[float] = field(default_factory=list)
frequency_units: str = 'Unknown'
phase_angles: Optional[List[float]] = field(default_factory=list)
phase_angle_units: str = 'Unknown'
power_density: Optional[List[float]] = None
power_units: str = 'Unknown'
energy_density: Optional[List[float]] = None
energy_units: str = 'Unknown'
max_amplitude: float = None
freq_at_max_amplitude: float = None
freq_increment: float = None
significant_amplitudes: List[Tuple[float, float, float]] = None
window_used: str = 'rectangular'
signal_duration: float = None
rms_spectrum: Optional[List[float]] = None
total_rms: Optional[float] = None
def __post_init__(self):
""" Checks that either amplitudes, power_density, or energy_density have been provided. Creates phase angles
if not provided. Automatically add this FrequencyDomainData to a parent project's collection list."""
if not (self.amplitudes or self.power_density or self.energy_density):
raise ValueError("ERROR: Either amplitudes, power_density, or energy_density must be provided.")
# Check lengths of frequencies and phase_angles
data_length = next(len(data) for data in [self.amplitudes, self.power_density, self.energy_density] if data)
if len(self.frequencies) != data_length:
raise ValueError("ERROR: Length of frequencies must match the length of the provided data.")
if self.phase_angles and len(self.phase_angles) != data_length:
raise ValueError("ERROR: Length of phase_angles must match the length of the provided data.")
if not self.phase_angles:
self.phase_angles = [0] * data_length
else:
warnings.warn("WARNING: No phase angles provided. Phase angles are set to 0 for all frequencies.")
# Calculate frequency increment
if len(self.frequencies) > 1:
increments = np.diff(self.frequencies)
if np.allclose(increments, increments[0]):
self.freq_increment = increments[0]
else:
raise ValueError("ERROR: Frequency increments are not consistent.")
else:
raise ValueError("ERROR: Not enough frequency data to calculate increments.")
if self.parent:
self.parent.add_frequency_domain_data_to_collection(self)
else:
warnings.warn(
"WARNING: No parent collection provided. This FrequencyDomainData will not be added to any collection.")
def __repr__(self):
""" Returns a string representation of the FrequencyDomainData."""
return f"FrequencyDomainData(name={self.name})"
[docs]
def check_frequency_data(self) -> None:
""" Checks the frequency data for consistency and validity."""
if not self.frequencies:
raise ValueError("ERROR: Frequency data is empty.")
if len(self.frequencies) != len(self.amplitudes):
raise ValueError("ERROR: Frequency and amplitude data lengths do not match.")
if any(f < 0 for f in self.frequencies):
raise ValueError("ERROR: Frequency data contains negative values.")
[docs]
def get_power_density(self, window_used: Optional[str] = None, recalculate: bool = False) -> List[float]:
"""
Method calculates or returns the power density of the frequency domain data.
Input:
- window_used (str): The window used for the FFT. If not provided, use self.window_used.
- recalculate (bool): Select to recalculate the power density overwriting the existing data. Default value
is False
Output:
- Returns the power density values as list of floats.
"""
if not self.power_density or recalculate:
if window_used is None:
window_used = self.window_used
self.power_density = power_density_form_amplitude_spectrum(
amplitude_spectrum=self.amplitudes, frequencies=self.frequencies, window_used=window_used)
# Units form the amplitude and frequency units
self.power_units = f"[{self.amplitude_units}]^2 / [{self.frequency_units}]"
return self.power_density
[docs]
def get_energy_density(self, window_used: Optional[str] = None, recalculate: bool = False) -> list[float]:
"""
Calculate the energy density by multiplying the power density by the signal duration.
Input:
- window_used (str): The window used for the FFT. If not provided, use self.window_used.
- recalculate (bool): Select to recalculate the energy density overwriting the existing data. Default value
is False
Output:
- Returns the energy density values as list of floats.
"""
if not self.signal_duration or recalculate:
raise ValueError(
"ERROR: The signal duration must be assigned to the signal_duration attribute to calculate energy "
"density.")
if not self.energy_density:
self.energy_density = [pd * self.signal_duration for pd in self.get_power_density(window_used=window_used)]
# Units form the amplitude and frequency units
self.power_units = f"[{self.amplitude_units}^2] / [{self.frequency_units}]^2"
return self.energy_density
[docs]
def get_rms_spectrum(self, window_used: Optional[str] = None, recalculate: bool = False) -> List[float]:
"""
Method to calculate or return the RMS spectrum of the frequency domain data.
Input:
- window_used (str): The window used for the FFT. If not provided, use self.window_used.
- recalculate (bool): Select to recalculate the energy density overwriting the existing data. Default value
is False
Output:
- Returns the RMS spectrum values as list of floats.
"""
if not self.rms_spectrum or recalculate:
power_density = self.get_power_density(window_used=window_used)
self.rms_spectrum = [rms_from_psd(pd, self.freq_increment) for pd in power_density]
return self.rms_spectrum
[docs]
def get_total_rms(self, window_used: Optional[str] = None, recalculate: bool = False) -> float:
"""
Method to calculate the total RMS of the frequency domain data.
Input:
- window_used (str): The window used for the FFT. If not provided, use self.window_used.
- recalculate (bool): Select to recalculate the energy density overwriting the existing data. Default value
is False
Output:
- Returns the total RMS value as float.
"""
if not self.total_rms or recalculate:
power_density = self.get_power_density(window_used=window_used)
self.total_rms = rms_from_psd(power_density, self.freq_increment)
return self.total_rms
[docs]
def find_significant_amplitudes(
self, amplitude_threshold: Optional[float] = None) -> List[Tuple[float, float, float]]:
"""
Method identifies the significant amplitudes and assigns them as an attribute to the FrequencyDomainData.
Input:
- amplitude_threshold (float): Optional input for the threshold for significant amplitudes. Default value
None, in which case 10% of max amplitude is used.
Output:
- Returns list of tuples with the significant amplitudes as float, the frequencies as float and phase angles
as float, in [deg].
"""
# Extract the necessary data
amplitudes = self.amplitudes
frequencies = self.frequencies
phase_angles = self.phase_angles
# Identify significant amplitudes
significant_amplitudes = identify_significant_amplitudes(
amplitudes=amplitudes, frequencies=frequencies, phase_angles=phase_angles,
amplitude_threshold=amplitude_threshold)
# Assign the significant amplitudes as an attribute
self.significant_amplitudes = significant_amplitudes
return significant_amplitudes
[docs]
def find_max_amplitude(self, frequency_range: Optional[List[float]] = None) -> Tuple[float, float]:
"""
Method to find the maximum amplitude within a given frequency range.
Input:
- frequency_range (list of floats): The frequency range to search for the maximum amplitude.
Output:
- Returns tuple with the maximum amplitude as float and its corresponding frequency as float.
"""
self.max_amplitude, self.freq_at_max_amplitude = find_max_amplitude(
amplitudes=self.amplitudes, frequencies=self.frequencies, frequency_range=frequency_range)
return self.max_amplitude, self.freq_at_max_amplitude
[docs]
def convert_to_time_domain(
self, name: Optional[str] = None, collection_name: Optional[str] = None) -> 'TimeDomainData':
"""
Method to convert the frequency domain data to time domain as TimeDomainData using inverse FFT. This method uses
the inverse Fast Fourier Transform (iFFT) to convert frequency domain data to time domain data. It creates a new
TimeDomainData object with the transformed data.
Input:
- name (str): Optional input for the name for the TimeDomainData. If not given, its created form the name
from the FrequencyDomainData self.
- collection_name (str): Optional input for the name of the collection to which the time domain data
belongs. If not provided, it uses the collection name from the FrequencyDomainData self.
Output:
- Returns the time domain data created from the frequency domain data.
- The time domain data is added to the time domain collection with the requested name.
"""
self.check_frequency_data()
# Perform inverse FFT to get time domain data
time_domain_amplitudes, time_stamps = \
time_domain_data_from_frequency_domain(self.amplitudes, self.phase_angles, self.frequencies)
if not name:
name = f"TimeDomain from {self.name}"
if not collection_name:
collection_name = self.parent.name
# Create a TimeDomainData object
return \
self.parent.parent.create_time_domain_data(
name=name, amplitude_type=self.amplitude_type, amplitudes=time_domain_amplitudes,
time_stamps=time_stamps, amplitude_units=self.amplitude_units,
time_stamps_units='1 / ' + self.frequency_units, collection_name=collection_name)
[docs]
def octaves(self, third_octave: bool = False, level_type: str = 'RMS') -> Dict[float, float]:
"""
Method to calculate the octave or third-octave bands of the FrequencyDomainData object depending on the octave
type.
Input:
- third_octave (bool): Input determines the frequency resolution of the output bands. When third-octave is
selected the function calculates third-octave bands, which divide each octave into three narrower bands.
Default value is False, the function calculates standard octave bands, which group frequencies more
broadly.
- level_type (str): Specifies the method used to compute the level of each frequency band. Options include
'peak', 'energy', 'power', and 'RMS'. Default value is 'RMS'.
Output:
- Returns the dictionary of the octave or third-octave bands.
"""
# Check input for the type of octaves
level_type = level_type.lower() if isinstance(level_type, str) else None
if level_type not in ['peak', 'energy', 'power', 'rms']:
raise ValueError(
"ERROR: Please select the type used to compute the level of each frequency band from: 'peak', "
"'energy', 'power', or 'RMS'.")
if level_type == 'peak':
return octave_form_frequency_domain_data(
quantities=self.amplitudes, frequencies=self.frequencies, third_octave=third_octave,
calculation_method=level_type)
power_density_octave = octave_form_frequency_domain_data(
quantities=self.get_power_density(), frequencies=self.frequencies, calculation_method='cumulative',
third_octave=third_octave)
power_octave = {k: v * self.freq_increment for k, v in power_density_octave.items()}
if level_type == 'power':
return power_octave
if level_type == 'energy':
return {k: v * self.signal_duration for k, v in power_octave.items()}
return {k: np.sqrt(v) for k, v in power_octave.items()}
[docs]
def plot(
self, spectrum_type: str = 'amplitude', log_scale_x: bool = False, log_scale_y: bool = False,
x_lim: Optional[List[float]] = None, y_lim: Optional[List[float]] = None,
file: Optional[Union[Path, str]] = None, show: bool = True, line_width: float = 1,
plot_max_values: bool = False) -> Optional[Path]:
"""
Plots the specified spectrum typ of the FrequencyDomainData object.
Input:
- spectrum_type (str): The type of spectrum to plot. Options are: 'amplitude', 'power density' or 'psd',
'energy density' or 'esd', and 'rms' or 'linear'. Default value is 'amplitude'.
- log_scale_x (bool): Select to set the x-axis to a logarithmic scale. Default value is False.
- log_scale_y (bool): Select to set the y-axis to a logarithmic scale. Default value is False.
- x_lim (list of float): Optional list to set limits [bottom limit, top limit] for the x-axis. Default value
is None.
- y_lim (list of float): Optional list to set limits [bottom limit, top limit] for the y-axis. Default value
is None.
- file (Path): Provide the full path and filename for the image of the graph to be created. The image is a
png-file, if the suffix is omitted it will be added. An error will occur if the picture format is set
to a non-supported format. It is also possible to provide the folder only, the title is used as filename.
- show (bool): Select to plot the graph in the environment when running the script. Default value True.
- line_width (float): Change line width, default = 1.
- plot_max_values (bool): Select to calculate the maximum y value for each line and add it to the plot.
Default value is False.
Output:
- Returns the path of the file if the plot was selected to be saved to a file, otherwise returns None.
"""
if not isinstance(spectrum_type, str):
raise ValueError("ERROR: Please provide the spectrum-type for the plot")
spectrum_type = spectrum_type.lower()
if spectrum_type == 'amplitude':
y_values = [self.amplitudes]
units = self.amplitude_units
elif spectrum_type in ['power density', 'psd']:
y_values = [self.get_power_density()]
units = self.power_units
elif spectrum_type in ['energy density', 'esd']:
y_values = [self.get_energy_density()]
units = self.power_units
elif spectrum_type in ['rms', 'linear']:
y_values = [self.get_rms_spectrum()]
units = self.amplitude_units
else:
raise NotImplementedError(f"ERROR: Unknown spectrum type for plot FrequencyDomainData: {spectrum_type}.")
return plot_function(
x_values=[self.frequencies], y_values=y_values, labels=[self.name],
title=f'{self.amplitude_type.capitalize()} {spectrum_type.capitalize()} Spectrum of {self.name}',
x_label=f'Frequency [{self.frequency_units}]',
y_label=f'{self.amplitude_type.capitalize()} {spectrum_type.capitalize()} [{units}]',
log_scale_x=log_scale_x, log_scale_y=log_scale_y, x_lim=x_lim, y_lim=y_lim, file=file, show=show,
line_width=line_width, plot_max_values=plot_max_values)
[docs]
def plot_octaves(
self, third_octave: bool = False, level_type: Optional[str] = None, log_scale_y: bool = False,
x_lim: Optional[List[float]] = None, y_lim: Optional[List[float]] = None,
file: Optional[Union[Path, str]] = None, show: bool = True, bar_width: float = 1,
show_bar_values: bool = True, decimals_displayed: int = 2) -> Optional[Path]:
"""
Plot the octave or third-octave bands of the FrequencyDomainData object.
Input:
- third_octave (bool): Input determines the frequency resolution of the output bands. When third-octave is
selected the function calculates third-octave bands, which divide each octave into three narrower bands.
Default value is False, the function calculates standard octave bands, which group frequencies more
broadly.
- level_type (str): Specifies the method used to compute the level of each frequency band. Options include
'peak', 'energy', 'power', and 'RMS'. Default value is 'RMS'.
- log_scale_y (bool): Select to set the y-axis to a logarithmic scale. Default value is False.
- x_lim (list of float): Optional list to set limits [bottom limit, top limit] for the x-axis. Default value
is None.
- y_lim (list of float): Optional list to set limits [bottom limit, top limit] for the y-axis. Default value
is None.
- file (Path): Provide the full path and filename for the image of the graph to be created. The image is a
png-file, if the suffix is omitted it will be added. An error will occur if the picture format is set
to a non-supported format. It is also possible to provide the folder only, the title is used as filename.
- show (bool): Select to plot the graph in the environment when running the script. Default value True.
- bar_width (float): The width of the bars. Default value is 1.
- show_bar_values (bool): Select to display the labels on the bars. Default value is True.
- decimals_displayed (int): The number of decimal places for formatting values. Default value is 2.
Output:
- Returns the path of the file if the plot was selected to be saved to a file, otherwise returns None.
"""
# Calculate octave or third-octave bands
bands = self.octaves(third_octave=third_octave, level_type=level_type)
# Set units based on the type
units = get_frequency_ban_level_units(
level_type=level_type, amplitude_units=self.amplitude_units, x_units=self.frequency_units)
# Plot using the grouped_bar_plot_function
return bar_plot_function(
x_values=list(bands.keys()), y_values=[list(bands.values())], labels=[self.name],
title=f'{level_type.capitalize()} {"Third " if third_octave else ""}Octave Band Plot of {self.name}',
x_label=f'Frequency [{self.frequency_units}]',
y_label=f'{self.amplitude_type.capitalize()} {level_type.capitalize()} [{units}]', log_scale_y=log_scale_y,
x_lim=x_lim, y_lim=y_lim, file=file, show=show, bar_width=bar_width,
show_bar_values=show_bar_values, decimals_displayed=decimals_displayed, )
### ===================================================================================================================
### 3. End of script
### ===================================================================================================================