Source code for rolland.postprocessing

"""Postprocessing classes.

.. autosummary::
    :toctree: postprocessing

    PostProcessing
    AnalyticPP
    RollandPP
    Response
    TDR
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field

import matplotlib.pyplot as plt
from numpy import array, convolve, ndarray, ones, pi, rint, round, squeeze, where, zeros  # noqa: A004
from numpy.fft import fft, fftfreq

from .deflection import Deflection
from .methods import AnalyticalMethods
from .track import (
    ArrangedBallastedSingleRailTrack,
    ArrangedSlabSingleRailTrack,
)


[docs] class PostProcessing(ABC): r"""Abstract base class for postprocessing classes."""
[docs] @abstractmethod def validate_postprocessing(self): """Validate the postprocessing methods."""
[docs] @staticmethod def fast_fourier_tr(tsignal, dt): """Calculate the Fast Fourier Transform (FFT) of a time signal. Parameters ---------- tsignal : numpy.ndarray Time signal to transform. dt : float Time step between samples. Returns ------- tuple Frequencies and FFT of the signal. """ samples = len(tsignal) window = ones(samples) fftrans = 2.0 / samples * fft(tsignal[:samples] * window) fftfre = fftfreq(samples, dt) return fftfre[0 : samples // 2], fftrans[0 : samples // 2]
[docs] @staticmethod def plot( arrays, labels, title='Universal Plot', x_label='X-axis', y_label='Y-axis', colors=None, plot_type='loglog', ): """Universal plot function for multiple data sets. Parameters ---------- arrays : list of tuple List of tuples, where each tuple contains two numpy.ndarray (x and y data). labels : list of str List of labels for each array. title : str, optional Title of the plot. Default is 'Universal Plot'. x_label : str, optional Label for the x-axis. Default is 'X-axis'. y_label : str, optional Label for the y-axis. Default is 'Y-axis'. colors : list of str, optional List of colors for each array. Default is None. plot_type : str, optional Type of plot (e.g., 'loglog', 'plot'). Default is 'loglog'. """ plt.figure(figsize=(10, 6)) if colors is None: colors = ['k', 'r', 'b', 'g', 'c', 'm', 'y'] for (x, y), label, color in zip(arrays, labels, colors, strict=False): if plot_type == 'loglog': plt.loglog(x, y, label=label, color=color) else: plt.plot(x, y, label=label, color=color) plt.xlabel(x_label) plt.ylabel(y_label) plt.title(title) plt.legend() plt.grid(True) plt.show()
[docs] class AnalyticPP(PostProcessing): r"""Analytic postprocessing class. This class is used to perform postprocessing on analytical methods. Attributes ---------- results : AnalyticalMethods Instance of the AnalyticalMethods class containing the results. """ def __init__(self, results: AnalyticalMethods): """Initialize AnalyticPP. Parameters ---------- results : AnalyticalMethods Instance of the AnalyticalMethods class containing the results. """ self.results = results
[docs] def validate_postprocessing(self): """Validate the postprocessing methods."""
@property def f(self): """Frequency vector.""" return self.results.f @property def vb(self): """Velocity vector.""" return self.results.mobility * self.results.force @property def ub(self): """Displacement vector.""" return self.vb / (self.results.omega * 1j)
[docs] @dataclass(kw_only=True) class RollandPP(PostProcessing): r"""Rolland postprocessing base class. This class is used to perform postprocessing on Rolland methods. Attributes ---------- results : Deflection Instance of the Deflection class containing the results. f_min : float Minimum frequency for response calculation :math:`[Hz]`. f_max : float Maximum frequency for response calculation :math:`[Hz]`. """ results: Deflection f_min: float = 100.0 f_max: float = 3000.0
[docs] def validate_postprocessing(self): """Validate the postprocessing methods."""
[docs] @dataclass(kw_only=True) class Response(RollandPP): r"""Postprocessing class for Rolland response quantities. This class calculates and stores response quantities such as receptance, mobility, and accelerance based on the results of the Deflection class. Attributes ---------- results : Deflection Instance of the Deflection class containing the results. x_resp : list[float] | None List of response points in meters :math:`[m]` (default value is x_excit). ind_resp : list[int] | None List of response indices (None if x_resp is provided). freq : numpy.ndarray Frequency vector :math:`[Hz]`. rez : numpy.ndarray Receptance vector :math:`[m/N]`. mob : numpy.ndarray Mobility vector :math:`[m/Ns]`. accel : numpy.ndarray Accelerance vector :math:`[m/Ns^2]`. """ x_resp: list[float] | None = None ind_resp: list[int] | None = None freq: ndarray = field(default_factory=lambda: array([]), metadata={"default_repr": "numpy.array([])"}) rez: ndarray = field(default_factory=lambda: array([]), metadata={"default_repr": "numpy.array([])"}) mob: ndarray = field(default_factory=lambda: array([]), metadata={"default_repr": "numpy.array([])"}) accel: ndarray = field(default_factory=lambda: array([]), metadata={"default_repr": "numpy.array([])"}) def __post_init__(self): """Initialize Response and calculate response quantities.""" self.calculate_response()
[docs] def calculate_response(self): """Calculate and store response quantities (Receptance, Mobility, Accelerance).""" if self.x_resp is None and self.ind_resp is None: self.x_resp = [self.results.discr.dx * self.results.ind_excit] self.ind_resp = [int(x / self.results.discr.dx) for x in self.x_resp] elif self.x_resp is None and self.ind_resp is not None: self.x_resp = [(x * self.results.discr.dx) for x in self.ind_resp] else: self.ind_resp = [int(x / self.results.discr.dx) for x in self.x_resp] # Compute force FFT once fftfre, ffft = self.fast_fourier_tr(self.results.force, self.results.discr.dt) # Initialize arrays for results n_points = len(self.ind_resp) n_freq = len(fftfre) ufft = zeros((n_points, n_freq), dtype=complex) # Compute deflection FFTs separately for each point for i, ind in enumerate(self.ind_resp): defl = self.results.deflection[ind, : self.results.discr.nt] _, ufft[i] = self.fast_fourier_tr(defl, self.results.discr.dt) # Calculate quantities for all points rez = ufft / ffft # Receptance mob = 1j * fftfre * 2 * pi * rez # Mobility accel = -((fftfre * 2 * pi) ** 2) * rez # Accelerance # Frequency range mask = (fftfre > self.f_min) & (fftfre <= self.f_max) # Store results as attributes self.freq = fftfre[mask] self.rez = squeeze(rez[:, mask]) self.mob = squeeze(mob[:, mask]) self.accel = squeeze(accel[:, mask])
[docs] @dataclass(kw_only=True) class TDR(RollandPP): r"""Postprocessing class for TDR (Track-Decay-Rate). This class calculates and stores the Track-Decay-Rate (TDR) based on :cite:`EN15461:2008`. Attributes ---------- results : Deflection Instance of the Deflection class containing the results. tdr : numpy.ndarray Track-Decay-Rate vector :math:`[dB/m]`. ind_tdr : list[int] Indices of the TDR points. x_tdr : numpy.ndarray Distances of the TDR points from the excitation point :math:`[m]`. filter : str | None Filter type (default is '1/3 Octave'). freq : numpy.ndarray Frequency vector :math:`[Hz]`. """ tdr: ndarray = field(default_factory=lambda: array([]), metadata={"default_repr": "numpy.array([])"}) filter: str | None = None freq: ndarray = field(default_factory=lambda: array([]), metadata={"default_repr": "numpy.array([])"}) def __post_init__(self): """Initialize TDR and calculate TDR values.""" self.find_tdr_points() self.calculate_tdr()
[docs] def find_tdr_points(self): """Find the corresponding measurement points depending on track type.""" if isinstance(self.results.track, ArrangedSlabSingleRailTrack | ArrangedBallastedSingleRailTrack): # TDR for non-uniform mounting positions # Identification of TDR positions # Determination of mounting positions x_mp = array(list(self.results.track.mount_prop.keys())) # Position ind_mp = (x_mp / self.results.discr.dx).astype(int) # Index # Left sleeper Index idx_s = int(where(ind_mp < self.results.ind_excit)[0][-1]) # Calculate distance from excitation point x_s = x_mp[idx_s:] - x_mp[idx_s] # Sleeper distances from excitation point. x_sc = convolve(x_s, ones(2) / 2, mode='valid') # Sleeper centers from excitation point. def tdr_points_betw1(idx): """Calculate of theoretical measurement points (1st part).""" return ((x_s[idx + 1] - x_sc[idx]) / 2) + x_sc[idx] def tdr_points_betw2(dx): """Calculate of theoretical measurement points (2nd part).""" return ((x_sc[dx] - x_s[dx]) / 2) + x_s[dx] # Theoretical measurement points self.x_tdr = array([x_sc[0], tdr_points_betw1(0), x_s[1], tdr_points_betw2(1), x_sc[1], tdr_points_betw1(1), x_s[2], tdr_points_betw2(2), x_sc[2], tdr_points_betw1(2), x_s[3], x_sc[3], x_s[4], x_sc[4], x_sc[5], x_sc[6], x_sc[7], x_sc[8], x_sc[10], x_sc[12], x_sc[16], x_sc[20], x_sc[24], x_sc[30], x_sc[36], x_sc[42], x_sc[48], x_sc[54], x_sc[66]]) - x_sc[0] # Determination of measurement position indices ind_tdr = rint(round(self.x_tdr, 5) / self.results.discr.dx) + self.results.ind_excit self.ind_tdr = list(ind_tdr.astype(int)) else: # TDR for continuous slab and ballasted tracks # Identification of TDR positions ind_excit = self.results.ind_excit # Start index. l_s = 0.6 # Theoretical Sleeper distance. x_tdr = array([0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3, 3.5, 4, 4.5, 5.5, 6.5, 7.5, 8.5, 10.5, 12.5, 16.5, 20.5, 24.5, 30.5, 36.5, 42.5, 48.5, 54.5, 66.5]) * l_s self.x_tdr = x_tdr - l_s / 2 ind_tdr = rint(self.x_tdr / self.results.discr.dx) + ind_excit self.ind_tdr = list(ind_tdr.astype(int))
[docs] def calculate_tdr(self): """Calculate the Track-Decay-Rate (TDR) based on the results.""" # Calculation of mobilities resp = Response(results=self.results, ind_resp=self.ind_tdr) mob = resp.mob # Calculation of TDR (according to DIN) sum_tdr = abs(mob[1, 1:]) ** 2 / abs(mob[0, 1:]) ** 2 * (self.x_tdr[1]) for n in range(2, len(self.ind_tdr)): sum_tdr = sum_tdr + abs(mob[n, 1:]) ** 2 / abs(mob[0, 1:]) ** 2 * (self.x_tdr[n]) self.tdr = 4.343 / sum_tdr self.freq = resp.freq[1:]