Coverage for src / sdynpy / core / sdynpy_data.py: 23%
3994 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 21:21 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-17 21:21 +0000
1# -*- coding: utf-8 -*-
2"""
3Defines the NDDataArray, which defines function data such as time histories.
5This module also defines several subclasses of NDDataArray, which contain
6function-type-specific capabilities. Several Enumerations are also defined
7that connect data fields from the universal file format to the NDDataArray
8subclasses.
9"""
10"""
11Copyright 2022 National Technology & Engineering Solutions of Sandia,
12LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the U.S.
13Government retains certain rights in this software.
15This program is free software: you can redistribute it and/or modify
16it under the terms of the GNU General Public License as published by
17the Free Software Foundation, either version 3 of the License, or
18(at your option) any later version.
20This program is distributed in the hope that it will be useful,
21but WITHOUT ANY WARRANTY; without even the implied warranty of
22MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23GNU General Public License for more details.
25You should have received a copy of the GNU General Public License
26along with this program. If not, see <https://www.gnu.org/licenses/>.
27"""
29import itertools
30import numpy as np
31from .sdynpy_array import SdynpyArray
32from .sdynpy_coordinate import outer_product, CoordinateArray, coordinate_array
33from .sdynpy_matrix import Matrix, matrix
34from ..signal_processing.sdynpy_correlation import mac
35from ..signal_processing.sdynpy_frf import timedata2frf
36from ..signal_processing.sdynpy_cpsd import (cpsd as sp_cpsd,
37 cpsd_coherence as sp_coherence,
38 cpsd_to_time_history,
39 cpsd_from_coh_phs,
40 db2scale,
41 nth_octave_freqs)
42from ..signal_processing.sdynpy_srs import (srs as sp_srs,
43 octspace,
44 sum_decayed_sines as sp_sds,
45 sum_decayed_sines_reconstruction,
46 sum_decayed_sines_displacement_velocity)
47from ..signal_processing.sdynpy_rotation import lstsq_rigid_transform
48from ..signal_processing.sdynpy_generator import (
49 pseudorandom, sine, ramp_envelope, chirp, pulse, sine_sweep)
50from ..signal_processing.sdynpy_frf_inverse import (frf_inverse,
51 compute_tikhonov_modified_singular_values)
52from ..signal_processing.sdynpy_harmonic import (
53 digital_tracking_filter as dtf,
54 vold_kalman_filter as vkf,
55 vold_kalman_filter_generator as vkf_gen)
57from ..fem.sdynpy_exodus import Exodus
58from scipy.linalg import eigh
59from scipy.optimize import minimize
60from enum import Enum
61import matplotlib
62import matplotlib.pyplot as plt
63import matplotlib.cm as cm
64from matplotlib.colors import ListedColormap
65from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
66from matplotlib.figure import Figure
67from matplotlib.patches import Rectangle
68from matplotlib.gridspec import GridSpec
69from matplotlib.ticker import MaxNLocator
70from copy import copy, deepcopy
71from datetime import datetime
72from qtpy import QtWidgets, uic, QtGui
73from qtpy.QtGui import QIcon, QFont
74from qtpy.QtCore import Qt, QCoreApplication, QRect
75from qtpy.QtWidgets import (QToolTip, QLabel, QPushButton, QApplication,
76 QGroupBox, QWidget, QMessageBox, QHBoxLayout,
77 QVBoxLayout, QSizePolicy, QMainWindow,
78 QFileDialog, QErrorMessage, QListWidget, QLineEdit,
79 QDockWidget, QGridLayout, QButtonGroup, QDialog,
80 QCheckBox, QRadioButton, QMenuBar, QMenu)
81try:
82 from qtpy.QtGui import QAction
83except ImportError:
84 from qtpy.QtWidgets import QAction
85import pyqtgraph
86import os
87import scipy.signal as sig
88import warnings
89import scipy.fft as scipyfft
90from scipy.signal.windows import exponential, get_window
91from scipy.signal import oaconvolve, convolve
92from scipy.interpolate import interp1d
93pyqtgraph.setConfigOption('background', 'w')
94pyqtgraph.setConfigOption('foreground', 'k')
97class SpecificDataType(Enum):
98 """Enumeration containing the types of data in universal files"""
99 UNKNOWN = 0
100 GENERAL = 1
101 STRESS = 2
102 STRAIN = 3
103 TEMPERATURE = 5
104 HEAT_FLUX = 6
105 DISPLACEMENT = 8
106 REACTION_FORCE = 9
107 VELOCITY = 11
108 ACCELERATION = 12
109 EXCITATION_FORCE = 13
110 PRESSURE = 15
111 MASS = 16
112 TIME = 17
113 FREQUENCY = 18
114 RPM = 19
115 ORDER = 20
116 SOUND_PRESSURE = 21
117 SOUND_INTENSITY = 22
118 SOUND_POWER = 23
121_specific_data_names = {val: val.name.replace('_', ' ').title() for val in SpecificDataType}
123_specific_data_names_vectorized = np.vectorize(_specific_data_names.__getitem__)
125# Table - Unit Exponents
126# -------------------------------------------------------
127# Specific Direction
128# ---------------------------------------------
129# Data Translational Rotational
130# ---------------------------------------------
131# Type Length Force Temp Length Force Temp
132# -------------------------------------------------------
133# 0 0 0 0 0 0 0
134# 1 (requires input to fields 2,3,4)
135# 2 -2 1 0 -1 1 0
136# 3 0 0 0 0 0 0
137# 5 0 0 1 0 0 1
138# 6 1 1 0 1 1 0
139# 8 1 0 0 0 0 0
140# 9 0 1 0 1 1 0
141# 11 1 0 0 0 0 0
142# 12 1 0 0 0 0 0
143# 13 0 1 0 1 1 0
144# 15 -2 1 0 -1 1 0
145# 16 -1 1 0 1 1 0
146# 17 0 0 0 0 0 0
147# 18 0 0 0 0 0 0
148# 19 0 0 0 0 0 0
149# --------------------------------------------------------
151_exponent_table = {
152 SpecificDataType.UNKNOWN: (0, 0, 0, 0, 0, 0, 0, 0),
153 SpecificDataType.GENERAL: (0, 0, 0, 0, 0, 0, 0, 0),
154 SpecificDataType.STRESS: (-2, 1, 0, 0, -1, 1, 0, 0),
155 SpecificDataType.STRAIN: (0, 0, 0, 0, 0, 0, 0, 0),
156 SpecificDataType.TEMPERATURE: (0, 0, 1, 0, 0, 0, 1, 0),
157 SpecificDataType.HEAT_FLUX: (1, 1, 0, 0, 1, 1, 0, 0),
158 SpecificDataType.DISPLACEMENT: (1, 0, 0, 0, 0, 0, 0, 0),
159 SpecificDataType.REACTION_FORCE: (0, 1, 0, 0, 1, 1, 0, 0),
160 SpecificDataType.VELOCITY: (1, 0, 0, -1, 0, 0, 0, -1),
161 SpecificDataType.ACCELERATION: (1, 0, 0, -2, 0, 0, 0, -2),
162 SpecificDataType.EXCITATION_FORCE: (0, 1, 0, 0, 1, 1, 0, 0),
163 SpecificDataType.PRESSURE: (-2, 1, 0, 0, -1, 1, 0, 0),
164 SpecificDataType.MASS: (-1, 1, 0, 2, 1, 1, 0, 2),
165 SpecificDataType.TIME: (0, 0, 0, 1, 0, 0, 0, 1),
166 SpecificDataType.FREQUENCY: (0, 0, 0, -1, 0, 0, 0, -1),
167 SpecificDataType.RPM: (0, 0, 0, -1, 0, 0, 0, -1)
168}
170_exponent_table_vectorized = np.vectorize(_exponent_table.__getitem__)
173class TypeQual(Enum):
174 """Enumeration containing the quantity type (Rotation or Translation)"""
175 TRANSLATION = 0
176 ROTATION = 1
179_type_qual_names = {val: val.name.replace('_', ' ').title() for val in TypeQual}
180_type_qual_names_vectorized = np.vectorize(_type_qual_names.__getitem__)
183class FunctionTypes(Enum):
184 """Enumeration containing types of functions found in universal files"""
185 GENERAL = 0
186 TIME_RESPONSE = 1
187 AUTOSPECTRUM = 2
188 CROSSSPECTRUM = 3
189 FREQUENCY_RESPONSE_FUNCTION = 4
190 TRANSMISIBILITY = 5
191 COHERENCE = 6
192 AUTOCORRELATION = 7
193 CROSSCORRELATION = 8
194 POWER_SPECTRAL_DENSITY = 9
195 ENERGY_SPECTRAL_DENSITY = 10
196 PROBABILITY_DENSITY_FUNCTION = 11
197 SPECTRUM = 12
198 CUMULATIVE_FREQUENCY_DISTRIBUTION = 13
199 PEAKS_VALLEY = 14
200 STRESS_PER_CYCLE = 15
201 STRAIN_PER_CYCLE = 16
202 ORBIT = 17
203 MODE_INDICATOR_FUNCTION = 18
204 FORCE_PATTERN = 19
205 PARTIAL_POWER = 20
206 PARTIAL_COHERENCE = 21
207 EIGENVALUE = 22
208 EIGENVECTOR = 23
209 SHOCK_RESPONSE_SPECTRUM = 24
210 FINITE_IMPULSE_RESPONSE_FILTER = 25
211 MULTIPLE_COHERENCE = 26
212 ORDER_FUNCTION = 27
213 PHASE_COMPENSATION = 28
214 IMPULSE_RESPONSE_FUNCTION = 29
217_imat_function_type_map = {'General': FunctionTypes.GENERAL,
218 'Time Response': FunctionTypes.TIME_RESPONSE,
219 'Auto Spectrum': FunctionTypes.AUTOSPECTRUM,
220 'Cross Spectrum': FunctionTypes.CROSSSPECTRUM,
221 'Frequency Response Function': FunctionTypes.FREQUENCY_RESPONSE_FUNCTION,
222 'Transmissibility': FunctionTypes.TRANSMISIBILITY,
223 'Coherence': FunctionTypes.COHERENCE,
224 'Auto Correlation': FunctionTypes.AUTOCORRELATION,
225 'Cross Correlation': FunctionTypes.CROSSCORRELATION,
226 'Power Spectral Density': FunctionTypes.POWER_SPECTRAL_DENSITY,
227 'Energy Spectral Density': FunctionTypes.ENERGY_SPECTRAL_DENSITY,
228 'Probability Density Function': FunctionTypes.PROBABILITY_DENSITY_FUNCTION,
229 'Spectrum': FunctionTypes.SPECTRUM,
230 'Cumulative Frequency Distribution': FunctionTypes.CUMULATIVE_FREQUENCY_DISTRIBUTION,
231 'Peaks Valley': FunctionTypes.PEAKS_VALLEY,
232 'Stress/Cycles': FunctionTypes.STRESS_PER_CYCLE,
233 'Strain/Cycles': FunctionTypes.STRAIN_PER_CYCLE,
234 'Orbit': FunctionTypes.ORBIT,
235 'Mode Indicator Function': FunctionTypes.MODE_INDICATOR_FUNCTION,
236 'Force Pattern': FunctionTypes.FORCE_PATTERN,
237 'Partial Power': FunctionTypes.PARTIAL_POWER,
238 'Partial Coherence': FunctionTypes.PARTIAL_COHERENCE,
239 'Eigenvalue': FunctionTypes.EIGENVALUE,
240 'Eigenvector': FunctionTypes.EIGENVECTOR,
241 'Shock Response Spectrum': FunctionTypes.SHOCK_RESPONSE_SPECTRUM,
242 'Finite Impulse Response Filter': FunctionTypes.FINITE_IMPULSE_RESPONSE_FILTER,
243 'Multiple Coherence': FunctionTypes.MULTIPLE_COHERENCE,
244 'Order Function': FunctionTypes.ORDER_FUNCTION,
245 'Phase Compensation': FunctionTypes.PHASE_COMPENSATION,
246 'Impulse Response Function': FunctionTypes.IMPULSE_RESPONSE_FUNCTION
247 }
249_imat_function_type_inverse_map = {val: key for key, val in _imat_function_type_map.items()}
252def _flat_frequency_shape(freq):
253 return 1
256class AbscissaIndexExtractor:
257 def __init__(self, parent):
258 self.parent = parent
260 def __getitem__(self, key):
261 return self.parent.extract_elements(key)
263 def __call__(self, key):
264 return self.parent.extract_elements(key)
267class AbscissaValueExtractor:
268 def __init__(self, parent):
269 self.parent = parent
271 def __getitem__(self, key):
272 return self.parent.extract_elements_by_abscissa(key[0], key[1])
274 def __call__(self, key):
275 return self.parent.extract_elements_by_abscissa(key[0], key[1])
278def _update_annotations_to_axes_bottom(axes):
279 annotations = [child for child in axes.get_children() if isinstance(child, matplotlib.text.Annotation)]
280 for annotation in annotations:
281 new_position = (annotation.xy[0],axes.get_ylim()[0])
282 annotation.xy = new_position
284class NDDataArray(SdynpyArray):
285 """Generic N-Dimensional data structure
287 This data structure can contain real or complex data. More specific
288 SDynPy data arrays inherit from this superclass.
289 """
291 def __new__(subtype, shape, nelements, data_dimension, ordinate_dtype='float64',
292 buffer=None, offset=0,
293 strides=None, order=None):
294 # Create the ndarray instance of our type, given the usual
295 # ndarray input arguments. This will call the standard
296 # ndarray constructor, but return an object of our type.
297 # It also triggers a call to __array_finalize__
298 data_dtype = [
299 ('abscissa', 'float64', (nelements,)),
300 ('ordinate', ordinate_dtype, (nelements,)),
301 ('comment1', '<U80'),
302 ('comment2', '<U80'),
303 ('comment3', '<U80'),
304 ('comment4', '<U80'),
305 ('comment5', '<U80'),
306 ('coordinate', CoordinateArray.data_dtype,
307 () if data_dimension is None else (data_dimension,))
308 ]
309 obj = super(NDDataArray, subtype).__new__(subtype, shape,
310 data_dtype, buffer, offset, strides, order)
311 # Finally, we must return the newly created object:
312 return obj
314 @property
315 def function_type(self):
316 """
317 Returns the function type of the data array as a FunctionTypes Enum
318 """
319 return FunctionTypes.GENERAL
321 @property
322 def response_coordinate(self):
323 """CoordinateArray corresponding to the response coordinates"""
324 return self.coordinate[..., 0]
326 @response_coordinate.setter
327 def response_coordinate(self, value):
328 """Set the response coordinate of the data array"""
329 self.coordinate[..., 0] = value
331 @property
332 def reference_coordinate(self):
333 """CoordinateArray corresponding to the response coordinates"""
334 if self.dtype['coordinate'].shape[0] == 1:
335 raise AttributeError('{:} has no reference coordinate'.format(self.__class__.__name__))
336 return self.coordinate[..., 1]
338 @reference_coordinate.setter
339 def reference_coordinate(self, value):
340 """Set the reference coordinate of the data array"""
341 if self.dtype['coordinate'].shape[0] == 1:
342 raise AttributeError('{:} has no reference coordinate'.format(self.__class__.__name__))
343 self.coordinate[..., 1] = value
345 @property
346 def num_elements(self):
347 """Number of elements in each data array"""
348 return self.dtype['ordinate'].shape[0]
350 @property
351 def num_coordinates(self):
352 """Number of coordinates defining the data array"""
353 return self.dtype['coordinate'].shape[0]
355 @property
356 def data_dimension(self):
357 """Number of dimensions to the data"""
358 return self.dtype['coordinate'].shape[-1]
360 @property
361 def idx_by_el(self):
362 """
363 AbscissaIndexExtractor that can be indexed to extract specific elements
364 """
365 return AbscissaIndexExtractor(self)
367 @property
368 def idx_by_ab(self):
369 """
370 AbscissaValueExtractor that can be indexed to extract an abscissa range
371 """
372 return AbscissaValueExtractor(self)
374 @property
375 def abscissa_spacing(self):
376 """The spacing of the abscissa in the function. Returns ValueError if
377 abscissa are not evenly spaced."""
378 # Look at the spacing between abscissa
379 spacing = np.diff(self.abscissa, axis=-1)
380 mean_spacing = np.mean(spacing)
381 if not np.allclose(spacing, mean_spacing):
382 raise ValueError('{:} do not have evenly spaced abscissa'.format(self.__class__.__name__))
383 return mean_spacing
385 def plot(self, one_axis: bool = True, subplots_kwargs: dict = {},
386 plot_kwargs: dict = {}, abscissa_markers = None,
387 abscissa_marker_labels = None, abscissa_marker_type = 'vline',
388 abscissa_marker_plot_kwargs = {}):
389 """
390 Plot the data array
392 Parameters
393 ----------
394 one_axis : bool, optional
395 Set to True to plot all data on one axis. Set to False to plot
396 data on multiple subplots. one_axis can also be set to a
397 matplotlib axis to plot data on an existing axis. The default is
398 True.
399 subplots_kwargs : dict, optional
400 Keywords passed to the matplotlib subplots function to create the
401 figure and axes. The default is {}.
402 plot_kwargs : dict, optional
403 Keywords passed to the matplotlib plot function. The default is {}.
404 abscissa_markers : ndarray, optional
405 Array containing abscissa values to mark on the plot to denote
406 significant events.
407 abscissa_marker_labels : str or ndarray
408 Array of strings to label the abscissa_markers with, or
409 alternatively a format string that accepts index and abscissa
410 inputs (e.g. '{index:}: {abscissa:0.2f}'). By default no label
411 will be applied.
412 abscissa_marker_type : str
413 The type of marker to use. This can either be the string 'vline'
414 or a valid matplotlib symbol specifier (e.g. 'o', 'x', '.').
415 abscissa_marker_plot_kwargs : dict
416 Additional keyword arguments used when plotting the abscissa label
417 markers.
419 Returns
420 -------
421 axis : matplotlib axis or array of axes
422 On which the data were plotted
424 """
425 if abscissa_markers is not None:
426 if abscissa_marker_labels is None:
427 abscissa_marker_labels = ['' for value in abscissa_markers]
428 elif isinstance(abscissa_marker_labels,str):
429 abscissa_marker_labels = [abscissa_marker_labels.format(
430 index = i, abscissa = v) for i,v in enumerate(abscissa_markers)]
432 if one_axis is True:
433 figure, axis = plt.subplots(**subplots_kwargs)
434 lines = axis.plot(self.flatten().abscissa.T, self.flatten().ordinate.T.real, **plot_kwargs)
435 if abscissa_markers is not None:
436 if abscissa_marker_type == 'vline':
437 kwargs = {'color':'k'}
438 kwargs.update(abscissa_marker_plot_kwargs)
439 for value,label in zip(abscissa_markers,abscissa_marker_labels):
440 axis.axvline(value, **kwargs)
441 axis.annotate(label, xy = (value, axis.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
442 axis.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
443 else:
444 for line in lines:
445 x = line.get_xdata()
446 y = line.get_ydata()
447 marker_y = np.interp(abscissa_markers, x, y)
448 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
449 kwargs.update(abscissa_marker_plot_kwargs)
450 axis.plot(abscissa_markers,marker_y,**kwargs)
451 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
452 axis.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
453 elif one_axis is False:
454 ncols = int(np.floor(np.sqrt(self.size)))
455 nrows = int(np.ceil(self.size / ncols))
456 figure, axis = plt.subplots(nrows, ncols, **subplots_kwargs)
457 for i, (ax, (index, function)) in enumerate(zip(axis.flatten(), self.ndenumerate())):
458 lines = ax.plot(function.abscissa.T, function.ordinate.T.real, **plot_kwargs)
459 ax.set_ylabel('/'.join([str(v) for i, v in function.coordinate.ndenumerate()]))
460 if abscissa_markers is not None:
461 if abscissa_marker_type == 'vline':
462 kwargs = {'color':'k'}
463 kwargs.update(abscissa_marker_plot_kwargs)
464 for value,label in zip(abscissa_markers,abscissa_marker_labels):
465 ax.axvline(value, **kwargs)
466 ax.annotate(label, xy = (value, ax.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
467 ax.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
468 else:
469 for line in lines:
470 x = line.get_xdata()
471 y = line.get_ydata()
472 marker_y = np.interp(abscissa_markers, x, y)
473 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
474 kwargs.update(abscissa_marker_plot_kwargs)
475 ax.plot(abscissa_markers,marker_y,**kwargs)
476 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
477 ax.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
478 for ax in axis.flatten()[i + 1:]:
479 ax.remove()
480 else:
481 axis = one_axis
482 lines = axis.plot(self.abscissa.T, self.ordinate.T.real, **plot_kwargs)
483 if abscissa_markers is not None:
484 if abscissa_marker_type == 'vline':
485 kwargs = {'color':'k'}
486 kwargs.update(abscissa_marker_plot_kwargs)
487 for value,label in zip(abscissa_markers,abscissa_marker_labels):
488 axis.axvline(value, **kwargs)
489 axis.annotate(label, xy = (value, axis.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
490 axis.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
491 else:
492 for line in lines:
493 x = line.get_xdata()
494 y = line.get_ydata()
495 marker_y = np.interp(abscissa_markers, x, y)
496 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
497 kwargs.update(abscissa_marker_plot_kwargs)
498 axis.plot(abscissa_markers,marker_y,**kwargs)
499 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
500 axis.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
501 return axis
503 def gui_plot(self,abscissa_markers = None,abscissa_marker_labels = None,
504 abscissa_marker_type = None, legend_label = None):
505 """
506 Create a GUIPlot window to visualize data.
508 Parameters
509 ----------
510 abscissa_markers : np.ndarray
511 Abscissa values at which markers will be placed. If not specified,
512 no markers will be added. Markers will be added to all plotted
513 curves if this argument is passed.
514 abscissa_marker_labels : str or iterable
515 Labels that will be applied to the markers. If not specified, no
516 label will be applied. If a single string is passed, it will be
517 passed to the `.format` method with keyword arguments `index` and
518 `abscissa`. Otherwise there should be one string for each marker.
519 abscissa_marker_type : str:
520 The type of marker that will be applied. Can be 'vline' for a
521 vertical line across the axis, or it can be a pyqtgraph symbol specifier
522 (e.g. 'x', 'o', 'star', etc.) which will be placed on the plotted curves.
523 If not specified, a vertical line will be used.
525 Returns
526 -------
527 GUIPlot
529 """
530 args = []
531 kwargs = {}
532 if legend_label is not None:
533 kwargs[legend_label] = self
534 else:
535 args.append(self)
536 if abscissa_markers is not None:
537 kwargs['abscissa_markers'] = abscissa_markers
538 if abscissa_marker_labels is not None:
539 kwargs['abscissa_marker_labels'] = abscissa_marker_labels
540 if abscissa_marker_type is not None:
541 kwargs['abscissa_marker_type'] = abscissa_marker_type
542 return GUIPlot(*args,**kwargs)
544 def plot_image(self,ax = None, reduction_function = None, colorbar_scale = 'linear',
545 colorbar_min = None, colorbar_max = None):
546 image_data = self.flatten().ordinate
547 if colorbar_scale == 'log':
548 image_data = np.log10(image_data)
549 if colorbar_min is not None:
550 colorbar_min = np.log10(colorbar_min)
551 if colorbar_max is not None:
552 colorbar_max = np.log10(colorbar_max)
553 def dof_formatter(x, pos):
554 if x < 0:
555 return ''
556 elif x >= self.size:
557 return ''
558 else:
559 return '/'.join([str(v) for v in self.flatten()[int(x)].coordinate])
560 if ax is None:
561 fig, ax = plt.subplots(1,1, layout='constrained')
562 im_data = ax.imshow(image_data,vmin=colorbar_min,vmax=colorbar_max,aspect='auto',
563 interpolation='none',extent=[
564 self.flatten()[0].abscissa[0]-0.5*self.abscissa_spacing,
565 self.flatten()[0].abscissa[-1]+0.5*self.abscissa_spacing,
566 self.size-0.5,-0.5])
567 ax.xaxis.set_major_locator(MaxNLocator(integer=True))
568 ax.yaxis.set_major_locator(MaxNLocator(min_n_ticks=1,integer=True))
569 ax.yaxis.set_major_formatter(dof_formatter)
570 if colorbar_scale == 'log':
571 plt.colorbar(
572 im_data,ax=ax,
573 format=lambda x,pos: '$10^{:}$'.format(x))
574 else:
575 plt.colorbar(
576 im_data,ax=ax)
577 return ax
579 def reshape_to_matrix(self, error_if_missing = True):
580 """
581 Reshapes a data array to a matrix with response coordinates along the
582 rows and reference coordinates along the columns
584 Parameters
585 ----------
586 error_if_missing : bool
587 If True, an error will be thrown if there are missing data objects
588 when trying to make a matrix of functions (i.e. if a response
589 degree of freedom is missing from one reference). If False,
590 response coordinates will simply be discarded if they do not exist
591 for all references. Default is True.
593 Returns
594 -------
595 output_array : Data Aarray
596 2D Array of NDDataArray
598 """
599 flattened_functions = np.ravel(self)
600 unique_coords = [
601 np.unique(self.coordinate[..., i]) for i in range(self.dtype["coordinate"].shape[0])
602 ]
603 coordinate_combinations = outer_product(*unique_coords)
604 output_array = self.__class__(coordinate_combinations.shape[:-1], self.num_elements)
605 if not error_if_missing:
606 if len(unique_coords) > 2:
607 raise NotImplementedError(
608 "error_if_missing == False is currently not implemented for data with dimension > 2"
609 )
610 keep_indices = np.ones(coordinate_combinations.shape[:-1], dtype=bool)
611 for indices in np.ndindex(coordinate_combinations.shape[:-1]):
612 coordinates = coordinate_combinations[indices]
613 current_function = flattened_functions[
614 np.all(flattened_functions.coordinate.abs() == coordinates.abs(), axis=-1)
615 ]
616 if current_function.size == 0:
617 if error_if_missing:
618 raise ValueError(f"No function exists with coordinates {coordinates}")
619 else:
620 keep_indices[indices] = False
621 continue
622 if current_function.size > 1:
623 raise ValueError(
624 f"Multiple functions exist ({current_function.size}) with coordinates {coordinate}"
625 )
626 output_array[indices] = current_function
627 if not error_if_missing:
628 keep_response_indices = np.all(keep_indices, axis=-1)
629 output_array = output_array[keep_response_indices,:]
630 return output_array
632 def extract_elements(self, indices):
633 """
634 Parses elements from the data array specified by the passed indices
636 Parameters
637 ----------
638 indices :
639 Any type of indices into a np.ndarray to select the elements to keep
641 Returns
642 -------
643 NDDataArray
644 Array reduced to specified elements
646 """
647 new_ordinate = self.ordinate[..., indices]
648 new_abscissa = self.abscissa[..., indices]
649 return data_array(self.function_type, new_abscissa, new_ordinate, self.coordinate,
650 self.comment1, self.comment2, self.comment3, self.comment4, self.comment5)
652 def extract_elements_by_abscissa(self, min_abscissa, max_abscissa):
653 """
654 Extracts elements with abscissa values within the specified range
656 Parameters
657 ----------
658 min_abscissa : float
659 Minimum abscissa value to keep
660 max_abscissa : float
661 Maximum abscissa value to keep.
663 Returns
664 -------
665 NDDataArray
666 Array reduced to specified elements.
668 """
669 abscissa_indices = (self.abscissa >= min_abscissa) & (self.abscissa <= max_abscissa)
670 indices = np.all(abscissa_indices, axis=tuple(np.arange(abscissa_indices.ndim - 1)))
671 new_ordinate = self.ordinate[..., indices]
672 new_abscissa = self.abscissa[..., indices]
673 return data_array(self.function_type, new_abscissa, new_ordinate, self.coordinate,
674 self.comment1, self.comment2, self.comment3, self.comment4, self.comment5)
676 @classmethod
677 def join(cls, data_arrays, increment_abscissa=True):
678 """
679 Joins several data arrays together by concatenating their ordinates
681 Parameters
682 ----------
683 data_arrays : NDDataArray
684 Arrays to concatenate
685 increment_abscissa : bool, optional
686 Determines how the abscissa concatenation is handled. If False,
687 the abscissa is left as it was in the original functions. If True,
688 it will be incremented so it is continuous.
690 Returns
691 -------
692 NDDataArray subclass
693 """
694 func_type = data_arrays[0].function_type
695 # Verify that coordinates are consistent
696 all_coordinate = np.array([array.coordinate for array in data_arrays]).view(CoordinateArray)
697 if not np.all(all_coordinate[:1] == all_coordinate):
698 raise ValueError('Signals do not have equivalent coordinates')
699 coordinate = data_arrays[0].coordinate
700 ordinate = np.concatenate([array.ordinate for array in data_arrays], axis=-1)
701 if increment_abscissa:
702 delta_abscissa = data_arrays[0].abscissa_spacing
703 abscissa = np.arange(ordinate.shape[-1])*delta_abscissa
704 else:
705 abscissa = np.concatenate([array[coordinate].abscissa for array in data_arrays], axis=-1)
706 return data_array(func_type, abscissa, ordinate, coordinate)
708 def downsample(self, factor):
709 """
710 Downsample a signal by keeping only every n-th abscissa/ordinate pair.
712 Parameters
713 ----------
714 factor : int
715 Downsample factor. Only the factor-th abcissa will be kept.
717 Returns
718 -------
719 NDDataArray
720 The downsampled data object
722 """
723 new_ordinate = self.ordinate[..., ::factor]
724 new_abscissa = self.abscissa[..., ::factor]
725 return data_array(self.function_type, new_abscissa, new_ordinate, self.coordinate,
726 self.comment1, self.comment2, self.comment3, self.comment4, self.comment5)
728 def validate_common_abscissa(self, **allclose_kwargs):
729 """
730 Returns True if all functions have the same abscissa
732 Parameters
733 ----------
734 **allclose_kwargs : various
735 Arguments to np.allclose to specify tolerances
737 Returns
738 -------
739 bool
740 True if all functions have the same abscissa
742 """
743 return np.allclose(self.flatten()[0].abscissa, self.abscissa, **allclose_kwargs)
745 def transform_coordinate_system(self, original_geometry, new_geometry, node_id_map=None, rotations=False):
746 """
747 Performs coordinate system transformations on the data
749 Parameters
750 ----------
751 original_geometry : Geometry
752 The Geometry in which the shapes are currently defined
753 new_geometry : Geometry
754 The Geometry in which the shapes are desired to be defined
755 node_id_map : id_map, optional
756 If the original and new geometries do not have common
757 node ids, an id_map can be specified to map the original geometry
758 node ids to new geometry node ids. The default is None, which means no
759 mapping will occur, and the geometries have common id numbers.
760 rotations : bool, optional
761 If True, also transform rotational degrees of freedom. The default
762 is False.
764 Returns
765 -------
766 NDDataArray or Subclass
767 A NDDataArray that can now be plotted with the new geometry
769 """
770 if self.data_dimension == 1:
771 if node_id_map is not None:
772 original_geometry = original_geometry.reduce(node_id_map.from_ids)
773 original_geometry.node.id = node_id_map(original_geometry.node.id)
774 self = self.copy()[np.isin(self.coordinate.node, node_id_map.from_ids)]
775 self.coordinate.node = node_id_map(self.coordinate.node)
776 common_nodes = np.intersect1d(np.intersect1d(original_geometry.node.id, new_geometry.node.id),
777 np.unique(self.coordinate.node))
778 coordinates = coordinate_array(
779 common_nodes[:, np.newaxis], [1, 2, 3, 4, 5, 6] if rotations else [1, 2, 3])
780 transform_from_original = original_geometry.global_deflection(coordinates)
781 transform_to_new = new_geometry.global_deflection(coordinates)
782 new_data_array = self[coordinates[..., np.newaxis]].copy()
783 shape_matrix = new_data_array.ordinate
784 new_shape_matrix = np.einsum('nij,nkj,nkl->nil', transform_to_new,
785 transform_from_original, shape_matrix)
786 new_data_array.ordinate = new_shape_matrix
787 return new_data_array.flatten()
788 else:
789 raise NotImplementedError('2D Data not Implemented Yet')
791 def to_shape_array(self, abscissa_values=None):
792 """
793 Converts an NDDataArray to a ShapeArray
795 Parameters
796 ----------
797 abscissa_values : ndarray, optional
798 Abscissa values at which the shapes will be created. The default is
799 to create shapes at all abscissa values. If an entry in
800 abscissa_values does not match a value in abscissa, the closest
801 abscissa value will be selected
803 Raises
804 ------
805 ValueError
806 If the data does not have common abscissa across all functions or if
807 duplicate response coordinates occur in the NDDataArray
809 Returns
810 -------
811 ShapeArray
812 ShapeArray containing the NDDataArray's ordinate as its shape_matrix
814 """
815 flat_self = self.flatten()
816 if not self.validate_common_abscissa():
817 raise ValueError('Data must have common abscissa to transform to `ShapeArray`')
818 if abscissa_values is None:
819 abscissa_indices = slice(None)
820 else:
821 abscissa_indices = np.argmin(abs(flat_self[0].abscissa - np.atleast_1d(abscissa_values)[:, np.newaxis]), axis=-1)
822 # Check if there are repeated responses
823 coordinates = flat_self.response_coordinate
824 if coordinates.size != np.unique(coordinates).size:
825 raise ValueError('Data has duplicate response coordinates. Please ensure that there is only one of each response coordinate in the data.')
826 # Extract the shape matrix
827 shape_matrix = flat_self.ordinate[:, abscissa_indices].T
828 # Create the new shape
829 from .sdynpy_shape import shape_array
830 return shape_array(coordinates, shape_matrix, flat_self[0].abscissa[abscissa_indices])
832 def zero_pad(self, num_samples=0, update_abscissa=True,
833 left=False, right=True,
834 use_next_fast_len=False):
835 """
836 Add zeros to the beginning or end of a signal
838 Parameters
839 ----------
840 num_samples : int, optional
841 Number of zeros to add to the function. If not specified, no zeros
842 are added unless `use_next_fast_len` is `True`
843 update_abscissa : bool, optional
844 If True, modify the abscissa to keep the same abscissa spacing.
845 The function must have equally spaced abscissa for this to work.
846 If False, the added abscissa will have a value of zero.
847 The default is True.
848 left : bool, optional
849 Add zeros to the left side (beginning) of the function. The default
850 is False. If both `left` and `right` are specified, the zeros will
851 be split half on the left and half on the right.
852 right : bool, optional
853 Add zeros to the right side (end) of the function. The default is
854 True. If both `left` and `right` are specified, the zeros will be
855 split half on the left and half on the right
856 use_next_fast_len : bool, optional
857 If True, potentially add additional zeros to the value specified by
858 `num_samples` to allow the total length of the final signal to reach
859 fast values for FFT as specified by `scipy.fft.next_fast_len`.
861 Returns
862 -------
863 NDDataArray subclass
864 The zero-padded version of the function
866 """
867 if use_next_fast_len:
868 total_samples = scipyfft.next_fast_len(self.num_elements + num_samples)
869 num_samples = total_samples - self.num_elements
870 # Create the additional zeros vectors
871 if left and (not right):
872 left_samples = num_samples
873 right_samples = 0
874 elif (not left) and right:
875 right_samples = num_samples
876 left_samples = 0
877 elif left and right:
878 left_samples = num_samples//2
879 right_samples = num_samples - left_samples
880 else:
881 left_samples = 0
882 right_samples = 0
883 added_zeros_left = np.zeros(self.shape+(left_samples,))
884 added_zeros_right = np.zeros(self.shape+(right_samples,))
886 new_ordinate = np.concatenate((added_zeros_left, self.ordinate, added_zeros_right), axis=-1)
888 if update_abscissa:
889 new_abscissa = self.abscissa_spacing*(np.arange(new_ordinate.shape[-1])-added_zeros_left.shape[-1]) + self.abscissa[..., 0, np.newaxis]
890 else:
891 new_abscissa = np.concatenate((added_zeros_left, self.abscissa, added_zeros_right), axis=-1)
893 return data_array(self.function_type, new_abscissa, new_ordinate,
894 self.coordinate, self.comment1, self.comment2,
895 self.comment3, self.comment4, self.comment5)
897 def interpolate(self, interpolated_abscissa, kind='linear', **kwargs):
898 """
899 Interpolates the NDDataArray using SciPy's interp1d.
901 Parameters
902 ----------
903 interpolated_abscissa : ndarray
904 Abscissa values at which to interpolate the function. If
905 multi-dimensional, it will be flattened.
906 kind : str or int, optional
907 Specifies the kind of interpolation as a string or as an integer
908 specifying the order of the spline interpolator to use. The string
909 has to be one of 'linear', 'nearest', 'nearest-up', 'zero',
910 'slinear', 'quadratic', 'cubic', 'previous', or 'next'.
911 'zero', 'slinear', 'quadratic' and 'cubic' refer to a spline
912 interpolation of zeroth, first, second or third order; 'previous'
913 and 'next' simply return the previous or next value of the point;
914 'nearest-up' and 'nearest' differ when interpolating half-integers
915 (e.g. 0.5, 1.5) in that 'nearest-up' rounds up and 'nearest' rounds
916 down. 'logx', 'logy', and 'loglog' use linear interpolation on
917 the values converted to log scale. Default is 'linear'.
918 **kwargs :
919 Additional arguments to scipy.interpolate.interp1d.
921 Returns
922 -------
923 NDDataArray :
924 Array with interpolated arguments
925 """
926 # Flatten the abscissa
927 interpolated_abscissa = np.reshape(interpolated_abscissa, -1)
928 # Create the output class
929 output = self.__class__(self.shape, interpolated_abscissa.size)
930 output.coordinate = self.coordinate
931 output.comment1 = self.comment1
932 output.comment2 = self.comment2
933 output.comment3 = self.comment3
934 output.comment4 = self.comment4
935 output.comment5 = self.comment5
936 output.abscissa = interpolated_abscissa
937 if kind == 'logx':
938 logx = True
939 logy = False
940 kind = 'linear'
941 elif kind == 'logy':
942 logx = False
943 logy = True
944 kind = 'linear'
945 elif kind == 'loglog':
946 logx=True
947 logy=True
948 kind = 'linear'
949 else:
950 logx = False
951 logy = False
952 if self.validate_common_abscissa():
953 x = np.log(self.flatten()[0].abscissa) if logx else self.flatten()[0].abscissa
954 y = np.log(self.ordinate) if logy else self.ordinate
955 interp = interp1d(x, y, kind=kind, axis=-1, **kwargs)
956 interpolated_ordinate = interp(np.log(interpolated_abscissa) if logx else interpolated_abscissa)
957 output.ordinate = np.exp(interpolated_ordinate) if logy else interpolated_ordinate
958 else:
959 for key, function in self.ndenumerate():
960 x = np.log(function.abscissa) if logx else function.abscissa
961 y = np.log(function.ordinate) if logy else function.ordinate
962 interp = interp1d(x, y, kind=kind, axis=-1, **kwargs)
963 interpolated_ordinate = interp(np.log(interpolated_abscissa) if logx else interpolated_abscissa)
964 output[key].ordinate = np.exp(interpolated_ordinate) if logy else interpolated_ordinate
965 return output
967 def __getitem__(self, key):
968 """
969 Selects specific data items by index or by coordinate
971 Parameters
972 ----------
973 key : CoordinateArray or indices
974 If key is a CoordinateArray, the returned NDDataArray will have the
975 specified coordinates. Otherwise, any form of indices can be passed
976 to select specific data arrays.
978 Returns
979 -------
980 NDDataArray
981 Data Array partitioned to the selected arrays.
982 """
983 if isinstance(key, CoordinateArray):
984 coordinate_dim = self.dtype['coordinate'].ndim
985 output_shape = key.shape[:-coordinate_dim]
986 flat_self = self.flatten()
987 index_array = np.empty(output_shape, dtype=int)
988 positive_coordinates = abs(flat_self.coordinate)
989 for index in np.ndindex(output_shape):
990 positive_key = abs(key[index])
991 try:
992 index_array[index] = np.where(
993 np.all(positive_coordinates == positive_key, axis=-1))[0][0]
994 except IndexError:
995 raise ValueError('Coordinate {:} not found in data array'.format(str(key[index])))
996 return_shape = flat_self[index_array].copy()
997 if self.function_type in [FunctionTypes.COHERENCE, FunctionTypes.MULTIPLE_COHERENCE]:
998 ordinate_multiplication_array = np.array(1)
999 else:
1000 ordinate_multiplication_array = np.prod(
1001 np.sign(return_shape.coordinate.direction) * np.sign(key.direction), axis=-1)
1002 # Set up for broadcasting
1003 ordinate_multiplication_array = ordinate_multiplication_array[..., np.newaxis]
1004 # Remove zeros and replace with 1s because we don't flip signs if
1005 # there is no direction associated with the coordinate
1006 ordinate_multiplication_array[ordinate_multiplication_array == 0] = 1
1007 return_shape.coordinate = key
1008 return_shape.ordinate *= ordinate_multiplication_array
1009 return return_shape
1010 else:
1011 output = super().__getitem__(key)
1012 if isinstance(key, str) and key == 'coordinate':
1013 return output.view(CoordinateArray)
1014 else:
1015 return output
1017 def __repr__(self):
1018 return '{:} with shape {:} and {:} elements per function'.format(self.__class__.__name__, ' x '.join(str(v) for v in self.shape), self.num_elements)
1020 def __add__(self, val):
1021 this = deepcopy(self)
1022 if isinstance(val, NDDataArray):
1023 # Check if abscissa are equivalent
1024 if not np.all(this.abscissa == val.abscissa):
1025 raise ValueError(
1026 'Binary operations on NDDataArrays require equivalent or broadcastably equivalent abscissa')
1027 this.ordinate += val.ordinate
1028 else:
1029 this.ordinate += val
1030 return this
1032 def __sub__(self, val):
1033 this = deepcopy(self)
1034 if isinstance(val, NDDataArray):
1035 # Check if abscissa are equivalent
1036 if not np.all(this.abscissa == val.abscissa):
1037 raise ValueError(
1038 'Binary operations on NDDataArrays require equivalent or broadcastably equivalent abscissa')
1039 this.ordinate -= val.ordinate
1040 else:
1041 this.ordinate -= val
1042 return this
1044 def __mul__(self, val):
1045 this = deepcopy(self)
1046 if isinstance(val, NDDataArray):
1047 # Check if abscissa are equivalent
1048 if not np.all(this.abscissa == val.abscissa):
1049 raise ValueError(
1050 'Binary operations on NDDataArrays require equivalent or broadcastably equivalent abscissa')
1051 this.ordinate *= val.ordinate
1052 else:
1053 this.ordinate *= val
1054 return this
1056 def __matmul__(self, other):
1057 if isinstance(other, Matrix):
1058 # We assume that if the matrix supplied has a "shape" to it, that
1059 # means that the multiplication should be done over "stacks" of
1060 # matrices and functions, so we should broadcast the arrays.
1061 # We have to make some assumptions, though. Otherwise there is
1062 # ambiguity. For example, if you had one 3x2 FRF matrix that
1063 # you wanted to multiply by 3 different matrices, there is a
1064 # difference between (3,1,1)@(3,2) = (3,3,2) and (3,1)@(3,2) = (3,2)
1065 # Therefore we will force the user to supply the correct
1066 # dimensionality to the matrix and the functions, and we will
1067 # do a kind of "reverse" broadcasting starting at the first
1068 # dimensions. Therefore, if a user has (3,) matrices and
1069 # wants to specify multiplying those (3,) matrices by a
1070 # single (3,2) FRF, they must pass a (1,3,2) FRF matrix
1071 # so the 1-dimension corresponds to the 3-dimension of the
1072 # matrix. If they wanted each of the 3 rows of the (3,2)
1073 # FRF to be multiplied by one of the 3 matrices, they should
1074 # pass a (3,) matrix and a (3,2) FRF. If they want each of
1075 # the 3 rows of the (3,2) FRF to be multiplied by a single
1076 # matrix, then they should make the matrix shape (1,) which
1077 # will be expanded out.
1078 matrix_shape = other.shape
1079 data_shape = self.shape
1080 matrix_dim = len(matrix_shape)
1081 data_dim = len(data_shape)
1082 # Make sure that the data dimension is greater than or equal to the matrix dimension due
1083 # to the broadcasting rules
1084 if data_dim < matrix_dim:
1085 raise ValueError("Matrix must have fewer dimensions than data")
1086 # Now make sure the first however many dimensions are broadcastable
1087 data_shape_for_broadcasting = data_shape[:matrix_dim]
1088 broadcast_shape = np.broadcast_shapes(matrix_shape, data_shape_for_broadcasting)
1089 # We don't know the size of the output array until we've actually computed the first,
1090 # so we set it to None as a placeholder
1091 output_data = None
1092 # Now we iterate through the broadcasted shape
1093 for indices in np.ndindex(broadcast_shape):
1094 # Replace indices with 0s if the original data was expanded from a length-1
1095 # dimension
1096 matrix_indices = tuple(
1097 [
1098 index if matrix_shape[dimension] > 1 else 0
1099 for dimension, index in enumerate(indices)
1100 ]
1101 )
1102 data_indices = tuple(
1103 [
1104 index if data_shape_for_broadcasting[dimension] > 1 else 0
1105 for dimension, index in enumerate(indices)
1106 ]
1107 )
1108 # Extract the data we are working with.
1109 matrix = other[matrix_indices]
1110 data = self[data_indices]
1111 # For the data, we need the matrix row degrees of freedom on the last
1112 # dimension, and all combinations of the remaining dimensions preceding it
1113 unique_dofs = []
1114 for dimension in range(data.dtype["coordinate"].shape[0] - 1):
1115 unique_dofs.append(np.unique(data.coordinate[..., dimension]))
1116 unique_dofs.append(matrix.row_coordinate)
1117 coordinate_combinations = outer_product(*unique_dofs)
1118 # Extract that data from the array
1119 data_sorted = data[coordinate_combinations]
1120 # Ensure that the abscissa is consistent
1121 output_abscissa = np.ravel(data_sorted)[0].abscissa
1122 if not np.allclose(output_abscissa, data_sorted.abscissa):
1123 raise ValueError("Data to be multiplied does not have common abscissa")
1124 # We currently have a matrix with shape (n,m) and data with ordinate shape (...,n,k)
1125 # where k is the number of samples in the data and n is the number of columns of data
1126 # and rows of the matrix. We will turn the ordinate of the data into a
1127 # (...,k,n) array
1128 ordinate_for_multiplication = np.moveaxis(data_sorted.ordinate, -2, -1)
1129 # After we do the multiplication to get the new ordinate, we have to return
1130 # the array to its proper shape with columns in front of samples
1131 new_ordinate = np.moveaxis((ordinate_for_multiplication @ matrix.matrix), -1, -2)
1132 # The new coordinates will be the outer product of the unique dofs, but now with
1133 # the column coordinates of the matrix replacing the row coordinates
1134 unique_dofs[-1] = matrix.column_coordinate
1135 output_combinations = outer_product(*unique_dofs)
1136 # Now we can assign these values to the output array. We may need to still
1137 # initialize it, however.
1138 if output_data is None:
1139 # We need to compute the final shape of the array, which will be the shape of
1140 # of the broadcast and the shape of the data.
1141 *this_data_shape, num_elements = new_ordinate.shape
1142 output_data_shape = broadcast_shape + tuple(this_data_shape)
1143 output_data = self.__class__(output_data_shape, num_elements)
1144 # Now we can assign the values
1145 output_data.ordinate[indices] = new_ordinate
1146 output_data.abscissa[indices] = output_abscissa
1147 output_data.coordinate[indices] = output_combinations
1148 return output_data
1149 return NotImplemented
1151 def __rmatmul__(self, other):
1152 if isinstance(other, Matrix):
1153 # We assume that if the matrix supplied has a "shape" to it, that
1154 # means that the multiplication should be done over "stacks" of
1155 # matrices and functions, so we should broadcast the arrays.
1156 # We have to make some assumptions, though. Otherwise there is
1157 # ambiguity. For example, if you had one 3x2 FRF matrix that
1158 # you wanted to multiply by 3 different matrices, there is a
1159 # difference between (3,1,1)@(3,2) = (3,3,2) and (3,1)@(3,2) = (3,2)
1160 # Therefore we will force the user to supply the correct
1161 # dimensionality to the matrix and the functions, and we will
1162 # do a kind of "reverse" broadcasting starting at the first
1163 # dimensions. Therefore, if a user has (3,) matrices and
1164 # wants to specify multiplying those (3,) matrices by a
1165 # single (3,2) FRF, they must pass a (1,3,2) FRF matrix
1166 # so the 1-dimension corresponds to the 3-dimension of the
1167 # matrix. If they wanted each of the 3 rows of the (3,2)
1168 # FRF to be multiplied by one of the 3 matrices, they should
1169 # pass a (3,) matrix and a (3,2) FRF. If they want each of
1170 # the 3 rows of the (3,2) FRF to be multiplied by a single
1171 # matrix, then they should make the matrix shape (1,) which
1172 # will be expanded out.
1173 # print("Extracting Dimensions:")
1174 matrix_shape = other.shape
1175 data_shape = self.shape
1176 matrix_dim = len(matrix_shape)
1177 data_dim = len(data_shape)
1178 # print(f"{matrix_shape=}\n{data_shape=}")
1179 # Make sure that the data dimension is greater than or equal to the matrix dimension due
1180 # to the broadcasting rules
1181 if data_dim < matrix_dim:
1182 raise ValueError("Matrix must have fewer dimensions than data")
1183 # Now make sure the first however many dimensions are broadcastable
1184 data_shape_for_broadcasting = data_shape[:matrix_dim]
1185 broadcast_shape = np.broadcast_shapes(matrix_shape, data_shape_for_broadcasting)
1186 # print(f"{broadcast_shape=}")
1187 # We don't know the size of the output array until we've actually computed the first,
1188 # so we set it to None as a placeholder
1189 output_data = None
1190 # Now we iterate through the broadcasted shape
1191 for indices in np.ndindex(broadcast_shape):
1192 # print(f"Multiplying {indices=}")
1193 # Replace indices with 0s if the original data was expanded from a length-1
1194 # dimension
1195 matrix_indices = tuple(
1196 [
1197 index if matrix_shape[dimension] > 1 else 0
1198 for dimension, index in enumerate(indices)
1199 ]
1200 )
1201 data_indices = tuple(
1202 [
1203 index if data_shape_for_broadcasting[dimension] > 1 else 0
1204 for dimension, index in enumerate(indices)
1205 ]
1206 )
1207 # print(f" {matrix_indices=}\n {data_indices=}")
1208 # Extract the data we are working with.
1209 matrix = other[matrix_indices]
1210 data = self[data_indices]
1211 # For the data, we need the matrix column degrees of freedom on the first
1212 # dimension, then all combinations of the remaining dimensions
1213 # print(f"Setting up coordinates")
1214 unique_dofs = [matrix.column_coordinate]
1215 for dimension in range(1, data.dtype["coordinate"].shape[0]):
1216 unique_dofs.append(np.unique(data.coordinate[..., dimension]))
1217 coordinate_combinations = outer_product(*unique_dofs)
1218 # print(f"{unique_dofs=}")
1219 # Extract that data from the array
1220 data_sorted = data[coordinate_combinations]
1221 # Ensure that the abscissa is consistent
1222 output_abscissa = np.ravel(data_sorted)[0].abscissa
1223 if not np.allclose(output_abscissa, data_sorted.abscissa):
1224 raise ValueError("Data to be multiplied does not have common abscissa")
1225 # We currently have a matrix with shape (n,m) and data with ordinate shape (m,...,k)
1226 # where k is the number of samples in the data and m is the number of rows of data
1227 # and columns of the matrix. We will turn the ordinate of the data into a
1228 # (...,k,m,1) array
1229 # print("Setting up Multiplication")
1230 ordinate_for_multiplication = np.moveaxis(data_sorted.ordinate, 0, -1)[
1231 ..., np.newaxis
1232 ]
1233 # print(f"{ordinate_for_multiplication.shape=}")
1234 # After we do the multiplication to get the new ordinate, we have to return
1235 # the array to its proper shape with rows out front and the last 1 dimension removed
1236 new_ordinate = np.moveaxis(
1237 (matrix.matrix @ ordinate_for_multiplication)[..., 0], -1, 0
1238 )
1239 # print("Multiplication Finished!")
1240 # print(f"{new_ordinate.shape=}")
1241 # The new coordinates will be the outer product of the unique dofs, but now with
1242 # the row coordinates of the matrix replacing the column coordinates
1243 unique_dofs[0] = matrix.row_coordinate
1244 output_combinations = outer_product(*unique_dofs)
1245 # print(f"Output Dofs: {unique_dofs=}")
1246 # Now we can assign these values to the output array. We may need to still
1247 # initialize it, however.
1248 if output_data is None:
1249 # We need to compute the final shape of the array, which will be the shape of
1250 # of the broadcast and the shape of the data.
1251 *this_data_shape, num_elements = new_ordinate.shape
1252 output_data_shape = broadcast_shape + tuple(this_data_shape)
1253 # print(f"Setting up output with {output_data_shape=} and {num_elements=}")
1254 output_data = self.__class__(output_data_shape, num_elements)
1255 # Now we can assign the values
1256 # print(f"Assigning Data to {indices=}")
1257 output_data.ordinate[indices] = new_ordinate
1258 output_data.abscissa[indices] = output_abscissa
1259 output_data.coordinate[indices] = output_combinations
1260 return output_data
1261 return NotImplemented
1263 def __truediv__(self, val):
1264 this = deepcopy(self)
1265 if isinstance(val, NDDataArray):
1266 # Check if abscissa are equivalent
1267 if not np.all(this.abscissa == val.abscissa):
1268 raise ValueError(
1269 'Binary operations on NDDataArrays require equivalent or broadcastably equivalent abscissa')
1270 this.ordinate /= val.ordinate
1271 else:
1272 this.ordinate /= val
1273 return this
1275 def __pow__(self, val):
1276 this = deepcopy(self)
1277 if isinstance(val, NDDataArray):
1278 # Check if abscissa are equivalent
1279 if not np.all(this.abscissa == val.abscissa):
1280 raise ValueError(
1281 'Binary operations on NDDataArrays require equivalent or broadcastably equivalent abscissa')
1282 this.ordinate **= val.ordinate
1283 else:
1284 this.ordinate **= val
1285 return this
1287 def __radd__(self, val):
1288 this = deepcopy(self)
1289 if isinstance(val, NDDataArray):
1290 # Check if abscissa are equivalent
1291 if not np.all(this.abscissa == val.abscissa):
1292 raise ValueError(
1293 'Binary operations on NDDataArrays require equivalent or broadcastably equivalent abscissa')
1294 this.ordinate += val.ordinate
1295 else:
1296 this.ordinate += val
1297 return this
1299 def __rsub__(self, val):
1300 this = deepcopy(self)
1301 if isinstance(val, NDDataArray):
1302 # Check if abscissa are equivalent
1303 if not np.all(this.abscissa == val.abscissa):
1304 raise ValueError(
1305 'Binary operations on NDDataArrays require equivalent or broadcastably equivalent abscissa')
1306 this.ordinate = val.ordinate - this.ordinate
1307 else:
1308 this.ordinate = val - this.ordinate
1309 return this
1311 def __rmul__(self, val):
1312 this = deepcopy(self)
1313 if isinstance(val, NDDataArray):
1314 # Check if abscissa are equivalent
1315 if not np.all(this.abscissa == val.abscissa):
1316 raise ValueError(
1317 'Binary operations on NDDataArrays require equivalent or broadcastably equivalent abscissa')
1318 this.ordinate *= val.ordinate
1319 else:
1320 this.ordinate *= val
1321 return this
1323 def __rtruediv__(self, val):
1324 this = deepcopy(self)
1325 if isinstance(val, NDDataArray):
1326 # Check if abscissa are equivalent
1327 if not np.all(this.abscissa == val.abscissa):
1328 raise ValueError(
1329 'Binary operations on NDDataArrays require equivalent or broadcastably equivalent abscissa')
1330 this.ordinate = val.ordinate / this.ordinate
1331 else:
1332 this.ordinate = val / this.ordinate
1333 return this
1335 def __rpow__(self, val):
1336 this = deepcopy(self)
1337 if isinstance(val, NDDataArray):
1338 # Check if abscissa are equivalent
1339 if not np.all(this.abscissa == val.abscissa):
1340 raise ValueError(
1341 'Binary operations on NDDataArrays require equivalent or broadcastably equivalent abscissa')
1342 this.ordinate = val.ordinate**this.ordinate
1343 else:
1344 this.ordinate = val**this.ordinate
1345 return this
1347 def __neg__(self):
1348 this = deepcopy(self)
1349 this.ordinate *= -1
1350 return this
1352 def __abs__(self):
1353 this = deepcopy(self)
1354 this.ordinate = np.abs(this.ordinate)
1355 return this
1357 def min(self, reduction=None, *min_args, **min_kwargs):
1358 """
1359 Returns the minimum ordinate in the data array
1361 Parameters
1362 ----------
1363 reduction : function, optional
1364 Optional function to modify the data, e.g. to select minimum of the
1365 absolute value. The default is None.
1366 *min_args : various
1367 Additional arguments passed to np.min
1368 **min_kwargs : various
1369 Additional keyword arguments passed to np.min
1371 Returns
1372 -------
1373 Value
1374 Minimum value in the ordinate.
1376 """
1377 if reduction is None:
1378 return np.min(self.ordinate, *min_args, **min_kwargs)
1379 else:
1380 return np.min(reduction(self.ordinate), *min_args, **min_kwargs)
1382 def max(self, reduction=None, *max_args, **max_kwargs):
1383 """
1384 Returns the maximum ordinate in the data array
1386 Parameters
1387 ----------
1388 reduction : function, optional
1389 Optional function to modify the data, e.g. to select maximum of the
1390 absolute value. The default is None.
1391 *max_args : various
1392 Additional arguments passed to np.max
1393 **max_kwargs : various
1394 Additional keyword arguments passed to np.max
1396 Returns
1397 -------
1398 Value
1399 Maximum value in the ordinate.
1400 """
1401 if reduction is None:
1402 return np.max(self.ordinate, *max_args, **max_kwargs)
1403 else:
1404 return np.max(reduction(self.ordinate), *max_args, **max_kwargs)
1406 def argmin(self, reduction=None, *argmin_args, **argmin_kwargs):
1407 """
1408 Returns the index of the minimum ordinate in the data array
1410 Parameters
1411 ----------
1412 reduction : function, optional
1413 Optional function to modify the data, e.g. to select minimum of the
1414 absolute value. The default is None.
1415 *argmin_args : various
1416 Additional arguments passed to np.argmax
1417 **argmin_kwargs : various
1418 Additional keyword arguments passed to np.argmax
1420 Returns
1421 -------
1422 int
1423 Index of the minimum of the flattened ordinate. Use
1424 np.unravel_index with self.ordinate.shape to get the unflattened
1425 index.
1426 """
1427 if reduction is None:
1428 return np.argmin(self.ordinate, *argmin_args, **argmin_kwargs)
1429 else:
1430 return np.argmin(reduction(self.ordinate), *argmin_args, **argmin_kwargs)
1432 def argmax(self, reduction=None, *argmax_args, **argmax_kwargs):
1433 """
1434 Returns the index of the maximum ordinate in the data array
1436 Parameters
1437 ----------
1438 reduction : function, optional
1439 Optional function to modify the data, e.g. to select maximum of the
1440 absolute value. The default is None.
1441 *argmax_args : various
1442 Additional arguments passed to np.argmax
1443 **argmax_kwargs : various
1444 Additional keyword arguments passed to np.argmax
1447 Returns
1448 -------
1449 int
1450 Index of the maximum of the flattened ordinate. Use
1451 np.unravel_index with self.ordinate.shape to get the unflattened
1452 index.
1453 """
1454 if reduction is None:
1455 return np.argmax(self.ordinate, *argmax_args, **argmax_kwargs)
1456 else:
1457 return np.argmax(reduction(self.ordinate), *argmax_args, **argmax_kwargs)
1459 def to_imat_struct_array(self, Version=1, SetRecord=0, CreateDate: datetime = None, ModifyDate: datetime = None,
1460 OwnerName='', AbscissaDataType=SpecificDataType.UNKNOWN, AbscissaTypeQual=TypeQual.TRANSLATION,
1461 AbscissaAxisLab='', AbscissaUnitsLab='',
1462 OrdNumDataType=SpecificDataType.UNKNOWN, OrdNumTypeQual=TypeQual.TRANSLATION,
1463 OrdDenDataType=SpecificDataType.UNKNOWN, OrdDenTypeQual=TypeQual.TRANSLATION,
1464 OrdinateAxisLab='', OrdinateUnitsLab='',
1465 ZAxisDataType=SpecificDataType.UNKNOWN, ZAxisTypeQual=TypeQual.TRANSLATION,
1466 ZGeneralValue=0, ZRPMValue=0, ZOrderValue=0, ZTimeValue=0,
1467 UserValue1=0, UserValue2=0, UserValue3=0, UserValue4=0,
1468 SamplingType='Dynamic', WeightingType='None', WindowType='None',
1469 AmplitudeUnits='Unknown', Normalization='Unknown', OctaveFormat=0,
1470 OctaveAvgType='None', ExpDampingFact=0, PulsesPerRev=0, MeasurementRun=0,
1471 LoadCase=0, IRIGTime='', verbose=False
1472 ):
1473 """
1474 Creates a Matlab structure that can be read the IMAT toolbox.
1476 This structure can be read by the IMAT toolbox in Matlab to create an
1477 imat_fn object. Note this is generally a slower function than
1478 to_imat_struct.
1480 Parameters
1481 ----------
1482 Version : int, optional
1483 The version number of the function. The default is 1.
1484 SetRecord : int, optional
1485 The set record of the function. The default is 0.
1486 CreateDate : datetime, optional
1487 The date that the function was created. The default is Now.
1488 ModifyDate : datetime, optional
1489 The date that the function was modified. The default is Now.
1490 OwnerName : str, optional
1491 The owner of the dataset. The default is ''.
1492 AbscissaDataType : SpecificDataType, optional
1493 The type of data associated with the Abscissa of the function.
1494 The default is SpecificDataType.UNKNOWN.
1495 AbscissaTypeQual : TypeQual, optional
1496 The qualifier associated with the abscissa of the function. The
1497 default is TypeQual.TRANSLATION.
1498 AbscissaAxisLab : str, optional
1499 String used to label the abscissa axis. The default is ''.
1500 AbscissaUnitsLab : str, optional
1501 String used to label the units on the abscissa axis. The default is ''.
1502 OrdNumDataType : SpecificDataType, optional
1503 The type of data associated with the numerator of the ordinate of
1504 the function. The default is SpecificDataType.UNKNOWN.
1505 OrdNumTypeQual : TypeQual, optional
1506 The qualifier associated with the numerator of the ordinate of the
1507 function. The default is TypeQual.TRANSLATION.
1508 OrdDenDataType : SpecificDataType, optional
1509 The type of data associated with the denominator of the ordinate of
1510 the function. The default is SpecificDataType.UNKNOWN.
1511 OrdDenTypeQual : TypeQual, optional
1512 The qualifier associated with the denominator of the ordinate of the
1513 function. The default is TypeQual.TRANSLATION.
1514 OrdinateAxisLab : str, optional
1515 String used to label the ordinate axis. The default is ''.
1516 OrdinateUnitsLab : TYPE, optional
1517 String used to label the units on the ordinate axis. The default is ''.
1518 ZAxisDataType : TYPE, optional
1519 DESCRIPTION. The default is SpecificDataType.UNKNOWN.
1520 ZAxisTypeQual : TYPE, optional
1521 DESCRIPTION. The default is TypeQual.TRANSLATION.
1522 ZGeneralValue : TYPE, optional
1523 DESCRIPTION. The default is 0.
1524 ZRPMValue : TYPE, optional
1525 DESCRIPTION. The default is 0.
1526 ZOrderValue : TYPE, optional
1527 DESCRIPTION. The default is 0.
1528 ZTimeValue : TYPE, optional
1529 DESCRIPTION. The default is 0.
1530 UserValue1 : TYPE, optional
1531 DESCRIPTION. The default is 0.
1532 UserValue2 : TYPE, optional
1533 DESCRIPTION. The default is 0.
1534 UserValue3 : TYPE, optional
1535 DESCRIPTION. The default is 0.
1536 UserValue4 : TYPE, optional
1537 DESCRIPTION. The default is 0.
1538 SamplingType : TYPE, optional
1539 DESCRIPTION. The default is 'Dynamic'.
1540 WeightingType : TYPE, optional
1541 DESCRIPTION. The default is 'None'.
1542 WindowType : TYPE, optional
1543 DESCRIPTION. The default is 'None'.
1544 AmplitudeUnits : TYPE, optional
1545 DESCRIPTION. The default is 'Unknown'.
1546 Normalization : TYPE, optional
1547 DESCRIPTION. The default is 'Unknown'.
1548 OctaveFormat : TYPE, optional
1549 DESCRIPTION. The default is 0.
1550 OctaveAvgType : TYPE, optional
1551 DESCRIPTION. The default is 'None'.
1552 ExpDampingFact : TYPE, optional
1553 DESCRIPTION. The default is 0.
1554 PulsesPerRev : TYPE, optional
1555 DESCRIPTION. The default is 0.
1556 MeasurementRun : TYPE, optional
1557 DESCRIPTION. The default is 0.
1558 LoadCase : TYPE, optional
1559 DESCRIPTION. The default is 0.
1560 IRIGTime : TYPE, optional
1561 DESCRIPTION. The default is ''.
1562 verbose : TYPE, optional
1563 DESCRIPTION. The default is False.
1565 Returns
1566 -------
1567 output_struct : np.ndarray
1568 A numpy structured array that can be saved to a mat file using
1569 scipy.io.savemat.
1571 """
1572 flat_self = self.flatten()
1573 dtype = [('FunctionType', object),
1574 ('Version', 'int'),
1575 ('SetRecord', 'int'),
1576 ('ResponseCoord', object),
1577 ('ReferenceCoord', object),
1578 ('IDLine1', object),
1579 ('IDLine2', object),
1580 ('IDLine3', object),
1581 ('IDLine4', object),
1582 ('CreateDate', object),
1583 ('ModifyDate', object),
1584 ('OwnerName', object),
1585 ('Abscissa', 'float', (self.num_elements,)),
1586 ('Ordinate', self.ordinate.dtype, (self.num_elements)),
1587 ('AbscissaDataType', object),
1588 ('AbscissaTypeQual', object),
1589 ('AbscissaExpLength', float),
1590 ('AbscissaExpForce', float),
1591 ('AbscissaExpTemp', float),
1592 ('AbscissaExpTime', float),
1593 ('AbscissaAxisLab', object),
1594 ('AbscissaUnitsLab', object),
1595 ('OrdinateType', object),
1596 ('OrdNumDataType', object),
1597 ('OrdNumTypeQual', object),
1598 ('OrdNumExpLength', float),
1599 ('OrdNumExpForce', float),
1600 ('OrdNumExpTemp', float),
1601 ('OrdNumExpTime', float),
1602 ('OrdDenDataType', object),
1603 ('OrdDenTypeQual', object),
1604 ('OrdDenExpLength', float),
1605 ('OrdDenExpForce', float),
1606 ('OrdDenExpTemp', float),
1607 ('OrdDenExpTime', float),
1608 ('OrdinateAxisLab', object),
1609 ('OrdinateUnitsLab', object),
1610 ('ZAxisDataType', object),
1611 ('ZAxisTypeQual', object),
1612 ('ZAxisExpLength', float),
1613 ('ZAxisExpForce', float),
1614 ('ZAxisExpTemp', float),
1615 ('ZAxisExpTime', float),
1616 ('ZGeneralValue', float),
1617 ('ZRPMValue', float),
1618 ('ZTimeValue', float),
1619 ('ZOrderValue', float),
1620 ('UserValue1', float),
1621 ('UserValue2', float),
1622 ('UserValue3', float),
1623 ('UserValue4', float),
1624 ('SamplingType', object),
1625 ('WeightingType', object),
1626 ('WindowType', object),
1627 ('AmplitudeUnits', object),
1628 ('Normalization', object),
1629 ('OctaveFormat', float),
1630 ('OctaveAvgType', object),
1631 ('ExpDampingFact', float),
1632 ('PulsesPerRev', float),
1633 ('MeasurementRun', int),
1634 ('LoadCase', int),
1635 ('IRIGTime', object)
1636 ]
1637 output_struct = np.empty(flat_self.shape,
1638 dtype=dtype)
1639 if verbose:
1640 print('Looping through functions for initial data')
1641 for i, fn in enumerate(flat_self):
1642 if fn.coordinate.shape == ():
1643 output_struct[i]['ResponseCoord'] = str(fn.coordinate)
1644 output_struct[i]['ReferenceCoord'] = ''
1645 else:
1646 output_struct[i]['ResponseCoord'] = str(fn.coordinate[0])
1647 if fn.coordinate.shape[0] > 1:
1648 output_struct[i]['ReferenceCoord'] = str(fn.coordinate[1])
1649 else:
1650 output_struct[i]['ReferenceCoord'] = ''
1651 output_struct[i]['Abscissa'] = fn.abscissa
1652 output_struct[i]['Ordinate'] = fn.ordinate
1653 output_struct[i]['IDLine1'] = fn.comment1
1654 output_struct[i]['IDLine2'] = fn.comment2
1655 output_struct[i]['IDLine3'] = fn.comment3
1656 output_struct[i]['IDLine4'] = fn.comment4
1657 output_struct[i]['FunctionType'] = _imat_function_type_inverse_map[self.function_type]
1659 if verbose:
1660 print('Assigning Version')
1661 output_struct['Version'] = np.broadcast_to(Version, flat_self.shape)
1662 if verbose:
1663 print('Assigning SetRecord')
1664 output_struct['SetRecord'] = np.broadcast_to(SetRecord, flat_self.shape)
1665 if CreateDate is None:
1666 CreateDate = datetime.now().strftime('%d-%b-%y %H:%M:%S')
1667 if verbose:
1668 print('Assigning CreateDate')
1669 output_struct['CreateDate'] = np.broadcast_to(CreateDate, flat_self.shape)
1670 if ModifyDate is None:
1671 ModifyDate = datetime.now().strftime('%d-%b-%y %H:%M:%S')
1672 if verbose:
1673 print('Assigning Modify Date')
1674 output_struct['ModifyDate'] = np.broadcast_to(ModifyDate, flat_self.shape)
1675 if verbose:
1676 print('Assigning OwnerName')
1677 output_struct['OwnerName'] = np.broadcast_to(OwnerName, flat_self.shape)
1678 # Abscissa
1679 if verbose:
1680 print('Assigning Abscissa Data')
1681 output_struct['AbscissaDataType'] = _specific_data_names_vectorized(
1682 np.broadcast_to(AbscissaDataType, flat_self.shape))
1683 output_struct['AbscissaTypeQual'] = _type_qual_names_vectorized(
1684 np.broadcast_to(AbscissaTypeQual, flat_self.shape))
1685 for i, (qual, datatype) in enumerate(zip(np.broadcast_to(AbscissaTypeQual, flat_self.shape), np.broadcast_to(AbscissaDataType, flat_self.shape))):
1686 exponents = _exponent_table[datatype]
1687 (output_struct['AbscissaExpLength'], output_struct['AbscissaExpForce'],
1688 output_struct['AbscissaExpTemp'], output_struct['AbscissaExpTime']
1689 ) = exponents[4:] if qual == TypeQual.ROTATION else exponents[:4]
1690 output_struct['AbscissaAxisLab'] = np.broadcast_to(AbscissaAxisLab, flat_self.shape)
1691 output_struct['AbscissaUnitsLab'] = np.broadcast_to(AbscissaUnitsLab, flat_self.shape)
1692 if verbose:
1693 print('Assigning Ordinate Numerator Data')
1694 # Ordinate Numerator
1695 output_struct['OrdNumDataType'] = _specific_data_names_vectorized(
1696 np.broadcast_to(OrdNumDataType, flat_self.shape))
1697 output_struct['OrdNumTypeQual'] = _type_qual_names_vectorized(
1698 np.broadcast_to(OrdNumTypeQual, flat_self.shape))
1699 for i, (qual, datatype) in enumerate(zip(np.broadcast_to(AbscissaTypeQual, flat_self.shape), np.broadcast_to(AbscissaDataType, flat_self.shape))):
1700 exponents = _exponent_table[datatype]
1701 (output_struct['OrdNumExpLength'], output_struct['OrdNumExpForce'],
1702 output_struct['OrdNumExpTemp'], output_struct['OrdNumExpTime']
1703 ) = exponents[4:] if qual == TypeQual.ROTATION else exponents[:4]
1704 # Ordinate Denominator
1705 if verbose:
1706 print('Assigning Ordinate Denominator Data')
1707 output_struct['OrdDenDataType'] = _specific_data_names_vectorized(
1708 np.broadcast_to(OrdDenDataType, flat_self.shape))
1709 output_struct['OrdDenTypeQual'] = _type_qual_names_vectorized(
1710 np.broadcast_to(OrdDenTypeQual, flat_self.shape))
1711 for i, (qual, datatype) in enumerate(zip(np.broadcast_to(AbscissaTypeQual, flat_self.shape), np.broadcast_to(AbscissaDataType, flat_self.shape))):
1712 exponents = _exponent_table[datatype]
1713 (output_struct['OrdDenExpLength'], output_struct['OrdDenExpForce'],
1714 output_struct['OrdDenExpTemp'], output_struct['OrdDenExpTime']
1715 ) = exponents[4:] if qual == TypeQual.ROTATION else exponents[:4]
1716 output_struct['OrdinateAxisLab'] = np.broadcast_to(OrdinateAxisLab, flat_self.shape)
1717 output_struct['OrdinateUnitsLab'] = np.broadcast_to(OrdinateUnitsLab, flat_self.shape)
1718 if self.ordinate.dtype == 'complex128':
1719 output_struct['OrdinateType'] = np.broadcast_to('Complex Double', flat_self.shape)
1720 elif self.ordinate.dtype == 'complex64':
1721 output_struct['OrdinateType'] = np.broadcast_to('Complex Single', flat_self.shape)
1722 if self.ordinate.dtype == 'float64':
1723 output_struct['OrdinateType'] = np.broadcast_to('Real Double', flat_self.shape)
1724 elif self.ordinate.dtype == 'float32':
1725 output_struct['OrdinateType'] = np.broadcast_to('Real Single', flat_self.shape)
1726 # Z Axis
1727 if verbose:
1728 print('Assigning ZAxis Data')
1729 output_struct['ZAxisDataType'] = _specific_data_names_vectorized(
1730 np.broadcast_to(ZAxisDataType, flat_self.shape))
1731 output_struct['ZAxisTypeQual'] = _type_qual_names_vectorized(
1732 np.broadcast_to(ZAxisTypeQual, flat_self.shape))
1733 for i, (qual, datatype) in enumerate(zip(np.broadcast_to(AbscissaTypeQual, flat_self.shape), np.broadcast_to(AbscissaDataType, flat_self.shape))):
1734 exponents = _exponent_table[datatype]
1735 (output_struct['ZAxisExpLength'], output_struct['ZAxisExpForce'],
1736 output_struct['ZAxisExpTemp'], output_struct['ZAxisExpTime']
1737 ) = exponents[4:] if qual == TypeQual.ROTATION else exponents[:4]
1738 output_struct['ZGeneralValue'] = np.broadcast_to(ZGeneralValue, flat_self.shape)
1739 output_struct['ZRPMValue'] = np.broadcast_to(ZRPMValue, flat_self.shape)
1740 output_struct['ZOrderValue'] = np.broadcast_to(ZOrderValue, flat_self.shape)
1741 output_struct['ZTimeValue'] = np.broadcast_to(ZTimeValue, flat_self.shape)
1742 if verbose:
1743 print('Assigning User Values')
1744 output_struct['UserValue1'] = np.broadcast_to(UserValue1, flat_self.shape)
1745 output_struct['UserValue2'] = np.broadcast_to(UserValue2, flat_self.shape)
1746 output_struct['UserValue3'] = np.broadcast_to(UserValue3, flat_self.shape)
1747 output_struct['UserValue4'] = np.broadcast_to(UserValue4, flat_self.shape)
1748 if verbose:
1749 print('Assigning Misc Data')
1750 output_struct['SamplingType'] = np.broadcast_to(SamplingType, flat_self.shape)
1751 output_struct['WeightingType'] = np.broadcast_to(WeightingType, flat_self.shape)
1752 output_struct['WindowType'] = np.broadcast_to(WindowType, flat_self.shape)
1753 output_struct['AmplitudeUnits'] = np.broadcast_to(AmplitudeUnits, flat_self.shape)
1754 output_struct['Normalization'] = np.broadcast_to(Normalization, flat_self.shape)
1755 output_struct['OctaveFormat'] = np.broadcast_to(OctaveFormat, flat_self.shape)
1756 output_struct['OctaveAvgType'] = np.broadcast_to(OctaveAvgType, flat_self.shape)
1757 output_struct['ExpDampingFact'] = np.broadcast_to(ExpDampingFact, flat_self.shape)
1758 output_struct['PulsesPerRev'] = np.broadcast_to(PulsesPerRev, flat_self.shape)
1759 output_struct['MeasurementRun'] = np.broadcast_to(MeasurementRun, flat_self.shape)
1760 output_struct['LoadCase'] = np.broadcast_to(LoadCase, flat_self.shape)
1761 output_struct['IRIGTime'] = np.broadcast_to(IRIGTime, flat_self.shape)
1763 return output_struct
1765 def save(self, filename, compress_abscissa = False):
1766 """
1767 Save the array to a numpy file
1769 Parameters
1770 ----------
1771 filename : str
1772 Filename that the array will be saved to. Will be appended with
1773 .npz if not specified in the filename
1774 compress_abscissa : bool, optional
1775 If True, abscissa will be stored as start and step instead of full
1776 arrays. If abscissa cannot be compressed and maintain values, a
1777 ValueError will be raised.
1779 """
1780 if compress_abscissa:
1781 abscissa_step = self.abscissa_spacing
1782 abscissa_start = np.ravel(self.abscissa)[0]
1783 if not np.all(abscissa_start==self.abscissa[...,0]):
1784 raise ValueError('Cannot compress abscissa with varying start values')
1785 fields = [field for field in self.fields if field != 'abscissa']
1786 np.savez(filename, data = self.view(np.ndarray)[fields].copy(),
1787 function_type = self.function_type.value,
1788 abscissa_start = abscissa_start,
1789 abscissa_step = abscissa_step)
1790 else:
1791 np.savez(filename, data=self.view(np.ndarray),
1792 function_type=self.function_type.value)
1794 @classmethod
1795 def load(cls, filename):
1796 """
1797 Load in the specified file into a SDynPy array object
1799 Parameters
1800 ----------
1801 filename : str
1802 Filename specifying the file to load. If the filename has
1803 extension .unv or .uff, it will be loaded as a universal file.
1804 Otherwise, it will be loaded as a NumPy file.
1806 Raises
1807 ------
1808 AttributeError
1809 Raised if a unv file is loaded from a class that does not have a
1810 from_unv attribute defined.
1812 Returns
1813 -------
1814 cls
1815 SDynpy array of the appropriate type from the loaded file.
1817 """
1818 if filename[-4:].lower() in ['.unv', '.uff']:
1819 try:
1820 from ..fileio.sdynpy_uff import readunv
1821 unv_dict = readunv(filename)
1822 return cls.from_unv(unv_dict)
1823 except AttributeError:
1824 raise AttributeError('Class {:} has no from_unv attribute defined'.format(cls))
1825 else:
1826 fn_data = np.load(filename, allow_pickle=True)
1827 if 'abscissa_start' in fn_data:
1828 # This is file with compressed abscissa
1829 data = fn_data['data']
1830 dtype = data.dtype.descr
1831 nelem = data['ordinate'].shape[-1]
1832 shape = data['ordinate'].shape[:-1]
1833 new_cls = _function_type_class_map[FunctionTypes(fn_data['function_type'])]
1834 new_obj = new_cls(shape,nelem)
1835 abscissa = fn_data['abscissa_start'] + np.arange(nelem)*fn_data['abscissa_step']
1836 for dt in dtype:
1837 if dt[0] not in new_obj.fields:
1838 continue
1839 new_obj[dt[0]] = data[dt[0]]
1840 new_obj['abscissa'] = abscissa
1841 return new_obj
1842 else:
1843 return fn_data['data'].view(
1844 _function_type_class_map[FunctionTypes(fn_data['function_type'])])
1846 def to_imat_struct(self, Version=None, SetRecord=None, CreateDate: datetime = None, ModifyDate: datetime = None,
1847 OwnerName=None, AbscissaDataType=None, AbscissaTypeQual=None,
1848 AbscissaAxisLab=None, AbscissaUnitsLab=None,
1849 OrdNumDataType=None, OrdNumTypeQual=None,
1850 OrdDenDataType=None, OrdDenTypeQual=None,
1851 OrdinateAxisLab=None, OrdinateUnitsLab=None,
1852 ZAxisDataType=None, ZAxisTypeQual=None,
1853 ZGeneralValue=None, ZRPMValue=None, ZOrderValue=None, ZTimeValue=None,
1854 UserValue1=None, UserValue2=None, UserValue3=None, UserValue4=None,
1855 SamplingType=None, WeightingType=None, WindowType=None,
1856 AmplitudeUnits=None, Normalization=None, OctaveFormat=None,
1857 OctaveAvgType=None, ExpDampingFact=None, PulsesPerRev=None, MeasurementRun=None,
1858 LoadCase=None, IRIGTime=None
1859 ):
1860 """
1861 Creates a Matlab structure that can be read the IMAT toolbox.
1863 This structure can be read by the IMAT toolbox in Matlab to create an
1864 imat_fn object. Note this is generally a faster function than
1865 to_imat_struct_array.
1868 Parameters
1869 ----------
1870 Version : TYPE, optional
1871 DESCRIPTION. The default is None.
1872 SetRecord : TYPE, optional
1873 DESCRIPTION. The default is None.
1874 CreateDate : datetime, optional
1875 DESCRIPTION. The default is None.
1876 ModifyDate : datetime, optional
1877 DESCRIPTION. The default is None.
1878 OwnerName : TYPE, optional
1879 DESCRIPTION. The default is None.
1880 AbscissaDataType : TYPE, optional
1881 DESCRIPTION. The default is None.
1882 AbscissaTypeQual : TYPE, optional
1883 DESCRIPTION. The default is None.
1884 AbscissaAxisLab : TYPE, optional
1885 DESCRIPTION. The default is None.
1886 AbscissaUnitsLab : TYPE, optional
1887 DESCRIPTION. The default is None.
1888 OrdNumDataType : TYPE, optional
1889 DESCRIPTION. The default is None.
1890 OrdNumTypeQual : TYPE, optional
1891 DESCRIPTION. The default is None.
1892 OrdDenDataType : TYPE, optional
1893 DESCRIPTION. The default is None.
1894 OrdDenTypeQual : TYPE, optional
1895 DESCRIPTION. The default is None.
1896 OrdinateAxisLab : TYPE, optional
1897 DESCRIPTION. The default is None.
1898 OrdinateUnitsLab : TYPE, optional
1899 DESCRIPTION. The default is None.
1900 ZAxisDataType : TYPE, optional
1901 DESCRIPTION. The default is None.
1902 ZAxisTypeQual : TYPE, optional
1903 DESCRIPTION. The default is None.
1904 ZGeneralValue : TYPE, optional
1905 DESCRIPTION. The default is None.
1906 ZRPMValue : TYPE, optional
1907 DESCRIPTION. The default is None.
1908 ZOrderValue : TYPE, optional
1909 DESCRIPTION. The default is None.
1910 ZTimeValue : TYPE, optional
1911 DESCRIPTION. The default is None.
1912 UserValue1 : TYPE, optional
1913 DESCRIPTION. The default is None.
1914 UserValue2 : TYPE, optional
1915 DESCRIPTION. The default is None.
1916 UserValue3 : TYPE, optional
1917 DESCRIPTION. The default is None.
1918 UserValue4 : TYPE, optional
1919 DESCRIPTION. The default is None.
1920 SamplingType : TYPE, optional
1921 DESCRIPTION. The default is None.
1922 WeightingType : TYPE, optional
1923 DESCRIPTION. The default is None.
1924 WindowType : TYPE, optional
1925 DESCRIPTION. The default is None.
1926 AmplitudeUnits : TYPE, optional
1927 DESCRIPTION. The default is None.
1928 Normalization : TYPE, optional
1929 DESCRIPTION. The default is None.
1930 OctaveFormat : TYPE, optional
1931 DESCRIPTION. The default is None.
1932 OctaveAvgType : TYPE, optional
1933 DESCRIPTION. The default is None.
1934 ExpDampingFact : TYPE, optional
1935 DESCRIPTION. The default is None.
1936 PulsesPerRev : TYPE, optional
1937 DESCRIPTION. The default is None.
1938 MeasurementRun : TYPE, optional
1939 DESCRIPTION. The default is None.
1940 LoadCase : TYPE, optional
1941 DESCRIPTION. The default is None.
1942 IRIGTime : TYPE, optional
1943 DESCRIPTION. The default is None.
1945 Returns
1946 -------
1947 data_dict : TYPE
1948 DESCRIPTION.
1950 """
1951 arguments = {key: val for key, val in locals().items() if not key ==
1952 'self'} # Get all the local variables that have been defined
1953 data_dict = {}
1954 data_dict['Ordinate'] = np.moveaxis(self.ordinate, -1, 0)
1955 data_dict['Abscissa'] = np.moveaxis(self.abscissa, -1, 0)
1956 data_dict['IDLine1'] = self.comment1
1957 data_dict['IDLine2'] = self.comment2
1958 data_dict['IDLine3'] = self.comment3
1959 data_dict['IDLine4'] = self.comment4
1960 data_dict['ResponseCoord'] = np.array(self.response_coordinate.string_array(), dtype=object)
1961 try:
1962 data_dict['ReferenceCoord'] = np.array(
1963 self.reference_coordinate.string_array(), dtype=object)
1964 except AttributeError:
1965 pass
1966 data_dict['FunctionType'] = _imat_function_type_inverse_map[self.function_type]
1967 for argument, data in arguments.items():
1968 if data is not None:
1969 if isinstance(data, datetime):
1970 data = data.strftime('%d-%b-%y %H:%M:%S')
1971 if isinstance(data, SpecificDataType):
1972 data = data.name.replace('_', ' ')
1973 data_dict[argument] = data
1974 return data_dict
1976 # def to_unv(self,function_id=1,version_number=1,load_case = 0,
1977 # response_entity_name = None,reference_entity_name = None,
1978 # abscissa_data_type = None,abscissa_length_exponent = None,
1979 # abscissa_force_exponent = None, abscissa_temp_exponent = None,
1980 # abscissa_axis_label = None,abscissa_units_label = None,
1981 # ordinate_num_data_type = None,ordinate_num_length_exponent = None,
1982 # ordinate_num_force_exponent = None,ordinate_num_temp_exponent = None,
1983 # ordinate_num_axis_label = None,ordinate_num_units_label = None,
1984 # ordinate_den_data_type = None,ordinate_den_length_exponent = None,
1985 # ordinate_den_force_exponent = None,ordinate_den_temp_exponent = None,
1986 # ordinate_den_axis_label = None,ordinate_den_units_label = None,
1987 # zaxis_data_type = None,zaxis_length_exponent = None,
1988 # zaxis_force_exponent = None,zaxis_temp_exponent = None,
1989 # zaxis_axis_label = None,zaxis_units_label = None,zaxis_value= None):
1990 # unv_data_dict = {58:[]}
1991 # for key,func in self.ndenumerate():
1992 # dataset_58 = sdpy.unv.dataset_58.Sdynpy_UFF_Dataset_58(
1993 # func.comment1,func.comment2,func.comment3,func.comment4,
1994 # func.comment5,func.function_type.value,)
1996 @staticmethod
1997 def from_unv(unv_data_dict, squeeze=True):
1998 """
1999 Create a data array from a unv dictionary from read_unv
2001 Parameters
2002 ----------
2003 unv_data_dict : dict
2004 Dictionary containing data from read_unv
2005 squeeze : bool, optional
2006 Automatically reduce dimension of the read data if possible.
2007 The default is True.
2009 Returns
2010 -------
2011 return_functions : NDDataArray
2012 Data read from unv
2014 """
2015 try:
2016 fn_datasets = unv_data_dict[58]
2017 except KeyError:
2018 return NDDataArray((0,),nelements=1,data_dimension=1)
2019 fn_types = [dataset.function_type for dataset in fn_datasets]
2020 function_type_dict = {}
2021 for fn_dataset, fn_type in zip(fn_datasets, fn_types):
2022 fn_type_enum = FunctionTypes(fn_type)
2023 if fn_type_enum not in function_type_dict:
2024 function_type_dict[fn_type_enum] = []
2025 function_type_dict[fn_type_enum].append(fn_dataset)
2026 return_functions = []
2027 for key, function_list in function_type_dict.items():
2028 abscissa = []
2029 ordinate = []
2030 coordinate = []
2031 comment1 = []
2032 comment2 = []
2033 comment3 = []
2034 comment4 = []
2035 comment5 = []
2036 for function in function_list:
2037 abscissa.append(function.abscissa)
2038 ordinate.append(function.ordinate)
2039 coordinate.append((coordinate_array(function.response_node, function.response_direction),
2040 coordinate_array(function.reference_node, function.reference_direction)))
2041 comment1.append(function.idline1)
2042 comment2.append(function.idline2)
2043 comment3.append(function.idline3)
2044 comment4.append(function.idline4)
2045 comment5.append(function.idline5)
2046 return_functions.append(
2047 data_array(key, np.array(abscissa), np.array(ordinate),
2048 np.array(coordinate).view(CoordinateArray),
2049 comment1,
2050 comment2,
2051 comment3,
2052 comment4,
2053 comment5)
2054 )
2055 if len(return_functions) == 1 and squeeze:
2056 return_functions = return_functions[0]
2057 return return_functions
2059 from_uff = from_unv
2061 def get_reciprocal_data(self,return_indices = False):
2062 """
2063 Gets reciprocal pairs of data from an NDDataArray.
2065 Parameters
2066 ----------
2067 return_indices : bool, optional
2068 If True, it will return a set of indices into the original array
2069 that extract the reciprocal functions. If False, then the
2070 reciprocal functions are returned directly. The default is False.
2072 Raises
2073 ------
2074 ValueError
2075 If the data does not have reference and response coordinates, the
2076 method will raise a ValueError.
2078 Returns
2079 -------
2080 np.ndarray or NDDataArray subclass
2081 If return_indices is True, this will return the indices into the
2082 original array that extract the reciprocal data. If return_indices
2083 is False, this will return the reciprocal NDDataArrays directly.
2085 """
2086 # Check if there are references and responses
2087 if self.dtype['coordinate'].shape != (2,):
2088 raise ValueError('Cannot compute reciprocal data of functions with only one coordinate')
2089 # Get all of the degrees of freedom that are both in references and
2090 # responses
2091 references = np.unique(abs(self.reference_coordinate))
2092 responses = np.unique(abs(self.response_coordinate))
2093 # Get common references and responses
2094 common_dofs = np.intersect1d(references,responses)
2095 # Get pairs of those degrees of freedom
2096 dof_combos = np.array([combo for combo in itertools.combinations(common_dofs,2)])
2097 # Now we need to select the reciprocal degrees of freedom
2098 reciprocal_dofs = np.array((dof_combos,dof_combos[...,::-1])).view(CoordinateArray)
2099 reciprocal_slice = tuple([Ellipsis]+self.ndim*[np.newaxis]+[slice(None)])
2100 equal_logical = np.all(abs(self.coordinate) == reciprocal_dofs[reciprocal_slice],axis=-1)
2101 equal_indices = np.where(equal_logical)
2102 equal_indices = tuple([inds.reshape(2,-1) for inds in equal_indices[2:]])
2103 if return_indices:
2104 return equal_indices
2105 else:
2106 return self[equal_indices]
2108 def get_drive_points(self,return_indices=False):
2109 """
2110 Returns data arrays where the reference is equal to the response
2112 Parameters
2113 ----------
2114 return_indices : bool, optional
2115 If True, it will return a set of indices into the original array
2116 that extract the drive point functions. If False, then the
2117 drive point functions are returned directly. The default is False.
2119 Raises
2120 ------
2121 ValueError
2122 If the data does not have reference and response coordinates, the
2123 method will raise a ValueError.
2125 Returns
2126 -------
2127 np.ndarray or NDDataArray subclass
2128 If return_indices is True, this will return the indices into the
2129 original array that extract the drive point data. If return_indices
2130 is False, this will return the drive point NDDataArrays directly.
2132 """
2133 # Check if there are references and responses
2134 if self.dtype['coordinate'].shape != (2,):
2135 raise ValueError('Cannot compute drive point data of functions with only one coordinate')
2136 equal_logical = abs(self.response_coordinate) == abs(self.reference_coordinate)
2137 if return_indices:
2138 equal_indices = np.where(equal_logical)
2139 return equal_indices
2140 else:
2141 return self[equal_logical]
2143 def shape_filter(self,shape, filter_responses = True, filter_references = False,
2144 rcond=None):
2145 """
2146 Spatially filters the data using the specified ShapeArray.
2148 Parameters
2149 ----------
2150 shape : ShapeArray
2151 A set of shapes used to filter the data
2152 filter_responses : bool, optional
2153 If True, will filter the response degrees of freedom. The default
2154 is True.
2155 filter_references : bool, optional
2156 If True, will filter the reference degrees of freedom. The default
2157 is False.
2158 rcond : float, optional
2159 Condition number threshold used in the pseudoinverse to compute the
2160 inverse of the specified shape arrays. The default is None.
2162 Raises
2163 ------
2164 ValueError
2165 Raised if the abscissa are not consistent across all data.
2167 Returns
2168 -------
2169 filtered_data : NDDataArray or subclass
2170 The type of the output will be the same type as the input, but
2171 filtered such that the degrees of freedom correspond to the shapes
2172 in the provided ShapeArray
2174 """
2175 if not self.validate_common_abscissa():
2176 raise ValueError('Abscissa must be consistent between data to filter.')
2177 # Reshape data into matrix form if multidimensional
2178 if self.coordinate.shape[-1] > 1:
2179 response_coords = np.unique(self.response_coordinate)
2180 reference_coords = np.unique(self.reference_coordinate)
2181 coords = outer_product(response_coords,reference_coords)
2182 data = self[coords]
2183 else:
2184 response_coords = np.unique(self.response_coordinate)
2185 reference_coords = None
2186 coords = response_coords[:,np.newaxis]
2187 data = self[coords]
2188 data_type=data.function_type
2190 if filter_responses:
2191 response_shape_matrix = np.linalg.pinv(shape[response_coords].T,rcond=rcond)
2192 output_response_coords = coordinate_array(np.arange(response_shape_matrix.shape[0])+1,0)
2193 else:
2194 response_shape_matrix = None
2195 output_response_coords = response_coords
2196 if reference_coords is not None and filter_references:
2197 reference_shape_matrix = np.linalg.pinv(shape[reference_coords].T,rcond=rcond)
2198 output_reference_coords = coordinate_array(np.arange(reference_shape_matrix.shape[0])+1,0)
2199 else:
2200 reference_shape_matrix = None
2201 output_reference_coords = reference_coords
2203 if output_reference_coords is not None:
2204 output_coords = outer_product(output_response_coords,output_reference_coords)
2205 else:
2206 output_coords = output_response_coords[:,np.newaxis]
2208 ordinate = data.ordinate
2209 if response_shape_matrix is not None:
2210 ordinate = np.einsum('mi,i...s->m...s',response_shape_matrix,ordinate)
2211 if reference_shape_matrix is not None:
2212 ordinate = np.einsum('mj,...js->...ms',reference_shape_matrix,ordinate)
2214 filtered_data = data_array(data_type=data_type,
2215 abscissa=data.reshape(-1)[0].abscissa,
2216 ordinate=ordinate,
2217 coordinate=output_coords)
2219 return filtered_data
2221 @staticmethod
2222 def get_abscissa_limits(data_arrays):
2223 """Compute the smallest overlapping x-axis range across one or more data arrays.
2225 Parameters
2226 ----------
2227 data_arrays : NDDataArray or iterable of NDDataArray
2228 A single data array or iterable of data arrays whose abscissa ranges are
2229 intersected to find the common (narrowest) x range.
2231 Returns
2232 -------
2233 list
2234 A two-element list ``[xmin, xmax]`` representing the intersection of all
2235 finite abscissa ranges across the provided arrays.
2236 """
2237 if isinstance(data_arrays, NDDataArray):
2238 xlim = [np.min(data_arrays.abscissa[~np.isnan(data_arrays.ordinate)]),
2239 np.max(data_arrays.abscissa[~np.isnan(data_arrays.ordinate)])]
2240 else:
2241 xlim = [None, None]
2242 for data_array in data_arrays:
2243 if np.any(~np.isnan(data_array.ordinate)):
2244 if any(lim is None for lim in xlim):
2245 xlim = [np.nanmin(data_array.abscissa[~np.isnan(data_array.ordinate)]),
2246 np.nanmax(data_array.abscissa[~np.isnan(data_array.ordinate)])]
2247 else:
2248 xlim = [max(np.nanmin(data_array.abscissa[~np.isnan(data_array.ordinate)]), min(xlim)),
2249 min(np.nanmax(data_array.abscissa[~np.isnan(data_array.ordinate)]), max(xlim))]
2250 return xlim
2252 @staticmethod
2253 def get_ordinate_limits(data_arrays, xlim: list):
2254 """Compute the y-axis limits for one or more data arrays within x bounds.
2256 Parameters
2257 ----------
2258 data_arrays : NDDataArray or iterable of NDDataArray
2259 A single data array or iterable of data arrays whose ordinate ranges are
2260 combined (union) to find the overall y range within ``xlim``.
2261 xlim : list
2262 Two-element list ``[xmin, xmax]`` restricting the abscissa range used
2263 when computing y limits.
2265 Returns
2266 -------
2267 list
2268 A two-element list ``[ymin, ymax]``.
2269 """
2270 if isinstance(data_arrays, NDDataArray):
2271 ordinate = data_arrays.extract_elements_by_abscissa(min(xlim), max(xlim)).ordinate
2272 if isinstance(data_arrays, PowerSpectralDensityArray):
2273 ylim = [np.min(np.abs(ordinate)), np.max(np.abs(ordinate))]
2274 else:
2275 ylim = [np.min(ordinate), np.max(ordinate)]
2276 else:
2277 ylim = [None, None]
2278 for data_array in data_arrays:
2279 if np.any(~np.isnan(data_array.ordinate)):
2280 ordinate = data_array.extract_elements_by_abscissa(min(xlim), max(xlim)).ordinate
2281 ordinate = ordinate[~np.isnan(ordinate)]
2282 if any(lim is None for lim in ylim):
2283 if isinstance(data_array, PowerSpectralDensityArray):
2284 ylim = [np.min(np.abs(ordinate)), np.max(np.abs(ordinate))]
2285 else:
2286 ylim = [np.min(ordinate), np.max(ordinate)]
2287 else:
2288 if isinstance(data_array, PowerSpectralDensityArray):
2289 ylim = [min(np.nanmin(np.abs(ordinate)), min(ylim)),
2290 max(np.nanmax(np.abs(ordinate)), max(ylim))]
2291 else:
2292 ylim = [min(np.nanmin(ordinate), min(ylim)),
2293 max(np.nanmax(ordinate), max(ylim))]
2294 return ylim
2296class TimeHistoryArray(NDDataArray):
2297 """Data array used to store time history data"""
2298 def __new__(subtype, shape, nelements, buffer=None, offset=0,
2299 strides=None, order=None):
2300 obj = super().__new__(subtype, shape, nelements, 1, 'float64', buffer, offset, strides, order)
2301 return obj
2303 @property
2304 def function_type(self):
2305 """
2306 Returns the function type of the data array
2307 """
2308 return FunctionTypes.TIME_RESPONSE
2310 @classmethod
2311 def from_exodus(cls, exo, x_disp='DispX', y_disp='DispY', z_disp='DispZ', x_rot=None, y_rot=None, z_rot=None, timesteps=None):
2312 """
2313 Reads time data from displacements in an Exodus file
2315 Parameters
2316 ----------
2317 exo : Exodus or ExodusInMemory
2318 The exodus data from which geometry will be created.
2319 x_disp : str, optional
2320 String denoting the nodal variable in the exodus file from which
2321 the X-direction displacement should be read. The default is 'DispX'.
2322 y_disp : str, optional
2323 String denoting the nodal variable in the exodus file from which
2324 the Y-direction displacement should be read. The default is 'DispY'.
2325 z_disp : str, optional
2326 String denoting the nodal variable in the exodus file from which
2327 the Z-direction displacement should be read. The default is 'DispZ'.
2328 timesteps : iterable, optional
2329 A list of timesteps from which data should be read. The default is
2330 None, which reads all timesteps.
2332 Returns
2333 -------
2334 TYPE
2335 DESCRIPTION.
2337 """
2338 if isinstance(exo, Exodus):
2339 node_ids = exo.get_node_num_map()
2340 variables = [(i + 1, v) for i, v in enumerate([x_disp, y_disp,
2341 z_disp, x_rot, y_rot, z_rot]) if v is not None]
2342 abscissa = exo.get_times()
2343 data = [data_array(FunctionTypes.TIME_RESPONSE, abscissa,
2344 exo.get_node_variable_values(variable, timesteps).T,
2345 coordinate_array(node_ids, index)[:, np.newaxis]) for index, variable in variables]
2346 return np.concatenate(data)
2347 else:
2348 # TODO need to add in rotations
2349 node_ids = np.arange(
2350 exo.nodes.coordinates.shape[0]) + 1 if exo.nodes.node_num_map is None else exo.nodes.node_num_map
2351 x_var = [var for var in exo.nodal_vars if var.name.lower() == x_disp.lower(
2352 )][0].data[slice(timesteps) if timesteps is None else timesteps]
2353 y_var = [var for var in exo.nodal_vars if var.name.lower() == y_disp.lower(
2354 )][0].data[slice(timesteps) if timesteps is None else timesteps]
2355 z_var = [var for var in exo.nodal_vars if var.name.lower() == z_disp.lower(
2356 )][0].data[slice(timesteps) if timesteps is None else timesteps]
2357 abscissa = exo.time[slice(timesteps) if timesteps is None else timesteps]
2358 ordinate = np.concatenate((x_var, y_var, z_var), axis=-1).T
2359 coordinates = coordinate_array(
2360 node_ids, np.array((1, 2, 3))[:, np.newaxis]).flatten()
2361 return data_array(FunctionTypes.TIME_RESPONSE, abscissa, ordinate, coordinates[:, np.newaxis])
2363 def fft(self, samples_per_frame=None, norm="backward", rtol=1, atol=1e-8,
2364 **scipy_rfft_kwargs):
2365 """
2366 Computes the frequency spectra of the time signal
2368 Parameters
2369 ----------
2370 samples_per_frame : int, optional
2371 Number of samples per measurement frame. If this is specified, then
2372 the signal will be split up into frames and averaged together. Be
2373 aware that if the time signal is not periodic, averaging it may have
2374 the effect of zeroing out the spectrum (because the average time
2375 signal is zero). The default is no averaging, the frame size is the
2376 length of the signal.
2377 norm : str, optional
2378 The type of normalization applied to the fft computation.
2379 The default is "backward".
2380 rtol : float, optional
2381 Relative tolerance used in the abcsissa spacing check.
2382 The default is 1e-5.
2383 atol : float, optional
2384 Relative tolerance used in the abscissa spacing check.
2385 The default is 1e-8.
2386 scipy_rfft_kwargs :
2387 Additional keywords that will be passed to SciPy's rfft function.
2389 Raises
2390 ------
2391 ValueError
2392 Raised if the time signal passed to this function does not have
2393 equally spaced abscissa.
2395 Returns
2396 -------
2397 SpectrumArray
2398 The frequency spectra of the TimeHistoryArray.
2400 """
2401 diffs = np.diff(self.abscissa, axis=-1).flatten()
2402 if not np.allclose(diffs, diffs[0], rtol, atol):
2403 raise ValueError('Abscissa must have identical spacing to perform the FFT')
2404 ordinate = self.ordinate
2405 if samples_per_frame is not None:
2406 frame_indices = np.arange(samples_per_frame) + np.arange(ordinate.size //
2407 samples_per_frame)[:, np.newaxis] * samples_per_frame
2408 ordinate = ordinate[..., frame_indices]
2409 dt = np.mean(diffs)
2410 n = ordinate.shape[-1]
2411 # frequencies = scipyfft.rfftfreq(n, dt)
2412 frequencies = scipyfft.rfftfreq(n, dt)
2413 # ordinate = scipyfft.rfft(ordinate, axis=-1)
2414 ordinate = scipyfft.rfft(ordinate, axis=-1, norm=norm,
2415 **scipy_rfft_kwargs)
2416 if samples_per_frame is not None:
2417 ordinate = np.mean(ordinate, axis=-2)
2418 # Create the output signal
2419 return data_array(FunctionTypes.SPECTRUM, frequencies, ordinate, self.coordinate,
2420 self.comment1, self.comment2, self.comment3, self.comment4, self.comment5)
2422 def zefft(self, num_steps = 10, abscissa_range = None,
2423 closest_zero_crossing = True):
2424 """Creates a FFTs from time histories with early times zeroed out
2426 This computes an FFT time history with the first portion of
2427 the original time history zeroed out. This is useful
2428 for analyzing ring-downs of nonlinear systems to
2429 identify how the system changes over time.
2431 Parameters
2432 ----------
2433 num_steps : int, optional
2434 The number of different levels of zeroing to
2435 compute for each signal, by default 10
2436 abscissa_range : iterable, optional
2437 A length-2 iterable defining the earliest and
2438 latest abscissa to use as a time to zero data until,
2439 by default, it will spread `num_steps` zero locations
2440 over the entire time signal.
2441 closest_zero_crossing : bool, optional
2442 If True, zeroing will start at a zero crossing in a
2443 signal that is closest to the specified zero time.
2444 Otherwise, the zeroing will start exactly
2445 where specified, by default True
2447 Returns
2448 -------
2449 SpectrumArray
2450 A SpectrumArray with an additional dimension for the
2451 `num_steps` specified. These are outputs of the
2452 `zero_early_time` method with the `fft` method
2453 subsequently applied.
2454 """
2455 return self.zero_early_time(num_steps, abscissa_range, closest_zero_crossing).fft()
2457 def zero_early_time(self, num_steps = 10, abscissa_range = None,
2458 closest_zero_crossing = True):
2459 """Creates a time histories with early times zeroed out
2461 This computes a time history with the first portion of
2462 the original time history zeroed out. This is useful
2463 for analyzing ring-downs of nonlinear systems to
2464 identify how the system changes over time.
2466 Parameters
2467 ----------
2468 num_steps : int, optional
2469 The number of different levels of zeroing to
2470 compute for each signal, by default 10
2471 abscissa_range : iterable, optional
2472 A length-2 iterable defining the earliest and
2473 latest abscissa to use as a time to zero data until,
2474 by default, it will spread `num_steps` zero locations
2475 over the entire time signal.
2476 closest_zero_crossing : bool, optional
2477 If True, zeroing will start at a zero crossing in a
2478 signal that is closest to the specified zero time.
2479 Otherwise, the zeroing will start exactly
2480 where specified, by default True
2482 Returns
2483 -------
2484 TimeHistoryArray
2485 A TimeHistoryArray with an additional dimension for the
2486 `num_steps` specified. Each of the `num_steps` set of
2487 signals has a different initial zero point.
2488 """
2489 output = np.tile(self,[num_steps]+[1 for i in range(self.ndim)])
2490 if not closest_zero_crossing:
2491 if abscissa_range is None:
2492 step_size = self.num_elements//(num_steps)
2493 zero_indices = np.broadcast_to(np.arange(num_steps)*step_size,
2494 self.shape+(num_steps,))
2495 else:
2496 abscissa_start_index = np.argmin(np.abs(self.abscissa-abscissa_range[0]),axis=-1)
2497 abscissa_end_index = np.argmin(np.abs(self.abscissa-abscissa_range[1]),axis=-1)
2498 step_size = (abscissa_end_index - abscissa_start_index)//(num_steps-1)
2499 zero_indices = np.arange(num_steps)*step_size[...,np.newaxis] + abscissa_start_index[...,np.newaxis]
2500 else:
2501 zero_crossing_indices, zero_crossing_abscissa = self.find_zero_crossings(True,True)
2502 zero_indices = np.zeros(self.shape+(num_steps,),dtype=int)
2503 if abscissa_range is None:
2504 step_size = self.num_elements//(num_steps)
2505 desired_zero_indices = np.arange(num_steps)*step_size
2506 for index,zero_crossings in np.ndenumerate(zero_crossing_indices):
2507 closest_zero_crossing_indices = [np.argmin(np.abs(desired_index-zero_crossings)) for desired_index in desired_zero_indices]
2508 zero_indices[index] = zero_crossings[closest_zero_crossing_indices]
2509 else:
2510 desired_abscissa = np.linspace(*abscissa_range,num_steps)
2511 for index,zero_abscissa in np.ndenumerate(zero_crossing_abscissa):
2512 closest_zero_crossing_indices = [np.argmin(np.abs(desired_abs-zero_abscissa)) for desired_abs in desired_abscissa]
2513 zero_indices[index] = zero_crossing_indices[index][closest_zero_crossing_indices]
2514 for step_index in range(num_steps):
2515 for signal_index in np.ndindex(self.shape):
2516 zero_index = zero_indices[signal_index+(step_index,)]
2517 output.ordinate[(step_index,)+signal_index+(slice(None,zero_index+1),)] = 0
2518 return output
2520 def find_zero_crossings(self,return_abscissa=False, include_start = False):
2521 """Finds zero crossings in the time history
2523 Parameters
2524 ----------
2525 return_abscissa : bool, optional
2526 If True, returns abscissa values associated with the
2527 zero crossing. By default it is False.
2528 include_start : bool, optional
2529 If True, returns the first index as a zero crossing,
2530 default is False
2532 Returns
2533 -------
2534 sign_changes_array : np.ndarray
2535 An array of indices into the ordinates of the
2536 function where sine changes occur.
2537 abscissa_array : np.ndarray
2538 An array of the abscissa values of the function
2539 where sine changes occur. Only returned if
2540 return_abscissa is True.
2541 """
2542 sign_changes = np.diff(np.sign(self.ordinate),axis=-1) != 0
2543 if include_start:
2544 sign_changes[...,0] = True
2545 sign_changes_array = np.empty(self.shape,dtype=object)
2546 if return_abscissa:
2547 abscissa_array = np.empty(self.shape,dtype=object)
2548 for index,fn in self.ndenumerate():
2549 sign_changes_array[index] = np.nonzero(sign_changes[index])[0]
2550 if return_abscissa:
2551 abscissa_array[index] = fn.abscissa[sign_changes_array[index]+1]
2552 if return_abscissa:
2553 return sign_changes_array,abscissa_array
2554 else:
2555 return sign_changes_array
2557 def cpsd(self, samples_per_frame: int, overlap: float, window: str,
2558 averages_to_keep: int = None,
2559 only_asds=False, rtol=1, atol=1e-8):
2560 """
2561 Computes a CPSD matrix from the time histories
2563 Parameters
2564 ----------
2565 samples_per_frame : int
2566 Number of samples per frame
2567 overlap : float
2568 Overlap fraction (not percent, e.g. 0.5 not 50)
2569 window : str
2570 Name of a window function in scipy.signal.windows
2571 averages_to_keep : int, optional
2572 Optional number of averages to use, otherwise as many as possible
2573 will be used.
2574 only_asds : bool, optional
2575 If True, only compute autospectral densities (diagonal of the
2576 CPSD matrix)
2577 rtol : float, optional
2578 Tolerance used to check abscissa spacing. The default is 1.
2579 atol : float, optional
2580 Tolerance used to check abscissa spacing. The default is 1e-8.
2582 Raises
2583 ------
2584 ValueError
2585 If time history abscissa are not equally spaced.
2587 Returns
2588 -------
2589 cpsd_array : PowerSpectralDensityArray
2590 Cross Power Spectral Density Array.
2592 """
2593 diffs = np.diff(self.abscissa, axis=-1).flatten()
2594 if not np.allclose(diffs, diffs[0], rtol, atol):
2595 raise ValueError('Abscissa must have identical spacing to perform the cpsd')
2596 flat_self = self.flatten()
2597 coords = flat_self.response_coordinate
2598 ordinate = flat_self.ordinate
2599 df, cpsd_matrix = sp_cpsd(ordinate, 1 / np.mean(diffs), samples_per_frame,
2600 overlap, window, averages_to_keep, only_asds)
2601 # Construct the spectrum array
2602 abscissa = np.arange(cpsd_matrix.shape[0]) * df
2603 cpsd_array = data_array(FunctionTypes.POWER_SPECTRAL_DENSITY, abscissa,
2604 np.moveaxis(cpsd_matrix, 0, -1),
2605 np.tile(coords[:, np.newaxis], [1, 2]) if only_asds
2606 else outer_product(coords, coords))
2607 return cpsd_array
2609 def srs(self, min_frequency=None, max_frequency=None, frequencies=None,
2610 damping=0.03, num_points=None, points_per_octave=12,
2611 srs_type='MMAA'):
2612 """
2613 Compute a shock response spectrum (SRS) from the time history
2615 Parameters
2616 ----------
2617 min_frequency : float, optional
2618 Minimum frequency to compute the SRS. Either `frequencies` or
2619 `min_frequency` and `max_frequency` must be specified.
2620 max_frequency : float, optional
2621 Maximum frequency to compute the SRS. Either `frequencies` or
2622 `min_frequency` and `max_frequency` must be specified.
2623 frequencies : np.ndarray, optional
2624 Frequency lines at which to compute the SRS. Either `frequencies` or
2625 `min_frequency` and `max_frequency` must be specified.
2626 damping : float, optional
2627 Fraction of critical damping to use in the SRS calculation (e.g. you
2628 should specify 0.03 to represent 3%, not 3). The default is 0.03.
2629 num_points : int, optional
2630 Number of frequency lines to compute from `min_frequency` to
2631 `max_frequency`, log spaced between these two values. If
2632 `min_frequency` and `max_frequency` are specified, then either
2633 `num_points` or `points_per_octave` must be specified. If
2634 `frequencies` is specified, this argument is ignored.
2635 points_per_octave : float, optional
2636 Number of frequency lines per octave to compute from `min_frequency`
2637 to `max_frequency`. If `min_frequency` and `max_frequency` are
2638 specified, then either `num_points` or `points_per_octave` must be
2639 specified. If `frequencies` is specified, this argument is ignored.
2640 The default is 12.
2641 srs_type : str, optional
2642 A string encoding for the type of SRS to be computed. See notes for
2643 more information.
2645 Returns
2646 -------
2647 ShockResponseSpectrumArray
2648 SRSs representing the current time histories. If `srs_type` is
2649 `'all'`, then an extra dimension of 9 will be added to the front of
2650 the array, and the indices in that dimension will be different SRS
2651 types.
2653 Notes
2654 -----
2655 The `srs_type` argument takes a 4 character string that specifies how
2656 the SRS is computed.
2657 """
2658 # Compute default parameters
2659 try:
2660 srs_type_val = ShockResponseSpectrumArray._srs_type_map[srs_type.lower()]
2661 except KeyError:
2662 raise ValueError('Invalid `srs_type` specified, should be one of {:} (case insensitive)'.format(
2663 [k for k in ShockResponseSpectrumArray._srs_type_map]))
2665 if frequencies is None:
2666 if min_frequency is None or max_frequency is None:
2667 raise ValueError('`min_frequency` and `max_frequency` must be provided if `frequencies` is not')
2668 if num_points is None:
2669 frequencies = octspace(min_frequency, max_frequency, points_per_octave)
2670 else:
2671 frequencies = np.logspace(np.log10(min_frequency), np.log10(max_frequency), num_points)
2673 srss, f = sp_srs(self.ordinate, self.abscissa_spacing, frequencies, damping, srs_type_val)
2674 if abs(srs_type_val) == 10:
2675 np.moveaxis(srss, -2, 0)
2677 # Now construct the output object
2678 srs = data_array(FunctionTypes.SHOCK_RESPONSE_SPECTRUM, frequencies.copy(),
2679 srss, self.coordinate.copy(),
2680 self.comment1.copy(), self.comment2.copy(), self.comment3.copy(), self.comment4.copy(),
2681 self.comment5.copy())
2682 return srs
2684 def hilbert(self, *hilbert_args, **hilbert_kwargs):
2685 """Computes the hilbert transform of the signal
2687 Any arguments passed to this method will be passed to the `scipy.signal.hilbert` function.
2689 Returns
2690 -------
2691 TimeHistoryArray
2692 A time history array containing the imaginary part of the analytic signal, which is the
2693 hilber transform of the signal.
2695 Raises
2696 ------
2697 ValueError
2698 If `axis` is specified as a keyword argument. The axis will automatically be set to the
2699 last dimension, which corresponds to the time samples in the signal.
2700 """
2701 if 'axis' in hilbert_kwargs:
2702 raise ValueError('axis cannot be specified because it is automatically -1')
2703 ana_sig = sig.hilbert(self.ordinate, *hilbert_args, **hilbert_kwargs, axis=-1)
2704 output = self.copy()
2705 output.ordinate[...] = ana_sig.imag
2706 return output
2708 def envelope(self, *envelope_args, **envelope_kwargs):
2709 """Computes the envelope of the time history array.
2711 Any arguments passed to htis method will be passed to the `scipy.signal.envelope` function.
2713 Returns
2714 -------
2715 TimeHistoryArray
2716 Returns a TimeHistoryArray object that includes the residual if `residual=None` is not
2717 prescribed. If `residual=None` is prescribed, then the output TimeHistoryArray will
2718 be the same size as the original TimeHistoryArray. If a residual is prescribed (the
2719 default case), then the output will be size (2, *self.shape), with the residual
2720 stacked along the first axis. This allows unpacking, e.g.,
2721 `envelope, residual = self.envelope()`.
2723 Raises
2724 ------
2725 ValueError
2726 If `axis` is specified as a keyword argument. The axis will automatically be set to the
2727 last dimension, which corresponds to the time samples in the signal.
2728 """
2729 if 'axis' in envelope_kwargs:
2730 raise ValueError('axis cannot be specified because it is automatically -1')
2731 env = sig.envelope(self.ordinate, *envelope_args, **envelope_kwargs)
2732 output = time_history_array(self.abscissa,env,self.coordinate,self.comment1, self.comment2, self.comment3, self.comment4, self.comment5)
2733 return output
2735 def filter(self,filter_order, frequency, filter_type = 'low',
2736 filter_method = 'filtfilt', filter_kwargs = None):
2737 """
2738 Filter the signal using a butterworth filter of the specified order
2740 Parameters
2741 ----------
2742 filter_order : int
2743 Order of the butterworth filter
2744 frequency : array_like
2745 The critical frequency or frequencies. For lowpass and highpass
2746 filters, this is a scalar; for bandpass and bandstop filters, this
2747 is a length-2 sequence (low,high).
2748 filter_type : str, optional
2749 Type of filter. Must be one of 'low','high','bandpass', or
2750 'bandstop'. The default is 'low'.
2751 filter_method : str, optional
2752 Method of filter application. Must be one of 'filtfilt' or 'lfilter'.
2753 The default is 'filtfilt'.
2754 filter_kwargs: dict, optional
2755 Optional keyword arguments to be passed to filtfilt or lfilter.
2757 Returns
2758 -------
2759 TimeHistoryArray
2760 The filtered time history array.
2762 """
2763 fs = 1/self.abscissa_spacing
2764 b,a = sig.butter(filter_order,frequency,filter_type,fs=fs)
2765 if filter_kwargs is None:
2766 filter_kwargs = {}
2767 if filter_method == 'filtfilt':
2768 filtered_ordinate = sig.filtfilt(b,a,self.ordinate,axis=-1,
2769 **filter_kwargs)
2770 elif filter_method == 'lfilter':
2771 filtered_ordinate = sig.lfilter(b,a,self.ordinate,axis=-1,
2772 **filter_kwargs)
2773 else:
2774 raise ValueError('filter_type must be one of filtfilt or lfilter')
2775 return_val = self.copy()
2776 return_val.ordinate = filtered_ordinate
2777 return return_val
2779 def vold_kalman_filter(self, arguments, filter_order = None,
2780 bandwidth = None, method = None, block_size = None,
2781 overlap = 0.5, plot_results = False, verbose = False):
2782 # If a blocksize is specified we will use the generator approach.
2783 # If not we will pass the entire thing to the VK filter.
2784 sample_rate = 1/self.abscissa_spacing
2785 amplitude_results = []
2786 phase_results = []
2787 if isinstance(arguments,TimeHistoryArray):
2788 arguments = arguments.ordinate
2789 arguments = np.atleast_2d(arguments)
2790 for key,signal in self.ndenumerate():
2791 if block_size is None or block_size > self.num_elements:
2792 sig,amp,phs = vkf(sample_rate,signal.ordinate,arguments,
2793 filter_order,bandwidth,method,True,verbose=verbose)
2794 amplitude_results.append(amp)
2795 phase_results.append(phs)
2796 else:
2797 generator = vkf_gen(sample_rate, arguments.shape[0], block_size,
2798 overlap, filter_order, bandwidth, method,
2799 plot_results=plot_results, verbose = verbose)
2800 generator.send(None)
2801 sample_index = 0
2802 signal_amplitude_results = []
2803 signal_phase_results = []
2804 while sample_index < self.num_elements:
2805 block_signal = signal.ordinate[sample_index:sample_index+block_size]
2806 block_arguments = arguments[...,sample_index:sample_index+block_size]
2807 last_signal = sample_index+block_size >= self.num_elements
2808 sig,amp,phs = generator.send((block_signal,block_arguments,last_signal))
2809 if sig is not None:
2810 signal_amplitude_results.append(amp)
2811 signal_phase_results.append(phs)
2812 sample_index += block_size
2813 amplitude_results.append(np.concatenate(signal_amplitude_results,axis=-1))
2814 phase_results.append(np.concatenate(signal_phase_results,axis=-1))
2815 final_shape = self.shape+(arguments.shape[0],self.num_elements)
2816 amplitude_results = np.reshape(amplitude_results,final_shape)
2817 phase_results = np.reshape(phase_results,final_shape)
2818 amplitude = time_history_array(self.abscissa[...,np.newaxis,:],
2819 amplitude_results,
2820 self.coordinate[...,np.newaxis,:],
2821 np.reshape(self.comment1,self.shape+(1,)),
2822 np.reshape(self.comment2,self.shape+(1,)),
2823 np.reshape(self.comment3,self.shape+(1,)),
2824 np.reshape(self.comment4,self.shape+(1,)),
2825 np.reshape(self.comment5,self.shape+(1,)))
2826 phases = time_history_array(self.abscissa[...,np.newaxis,:],
2827 phase_results,
2828 self.coordinate[...,np.newaxis,:],
2829 np.reshape(self.comment1,self.shape+(1,)),
2830 np.reshape(self.comment2,self.shape+(1,)),
2831 np.reshape(self.comment3,self.shape+(1,)),
2832 np.reshape(self.comment4,self.shape+(1,)),
2833 np.reshape(self.comment5,self.shape+(1,)))
2834 return amplitude,phases
2836 def digital_tracking_filter(self, frequencies, arguments,
2837 cutoff_frequency_ratio = 0.15,
2838 filter_order = 2, phase_estimate = None,
2839 amplitude_estimate = None, block_size = None,
2840 plot_results = False
2841 ):
2842 dt = self.abscissa_spacing
2843 amplitude_results = []
2844 phase_results = []
2845 if isinstance(frequencies,TimeHistoryArray):
2846 frequencies = frequencies.ordinate
2847 if isinstance(arguments,TimeHistoryArray):
2848 arguments = arguments.ordinate
2849 for key,signal in self.ndenumerate():
2850 xs = signal.ordinate
2851 amp,phs = dtf(dt,xs,frequencies,arguments,cutoff_frequency_ratio,
2852 filter_order, phase_estimate, amplitude_estimate,
2853 block_size, plot_results)
2854 amplitude_results.append(amp)
2855 phase_results.append(phs)
2856 amplitude_results = np.reshape(amplitude_results, self.ordinate.shape)
2857 phase_results = np.reshape(phase_results,self.ordinate.shape)
2858 amplitudes = time_history_array(self.abscissa,amplitude_results,
2859 self.coordinate, self.comment1,
2860 self.comment2, self.comment3, self.comment4,
2861 self.comment5)
2862 phases = time_history_array(self.abscissa,phase_results,
2863 self.coordinate, self.comment1,
2864 self.comment2, self.comment3, self.comment4,
2865 self.comment5)
2866 return amplitudes, phases
2868 def split_into_frames(self, samples_per_frame=None, frame_length=None,
2869 overlap=None, overlap_samples=None, window=None,
2870 check_cola=False, allow_fractional_frames=False):
2871 """
2872 Splits a time history into measurement frames with a given overlap and
2873 window function applied.
2875 Parameters
2876 ----------
2877 samples_per_frame : int, optional
2878 Number of samples in each measurement frame. Either this argument
2879 or `frame_length` must be specified. If both or neither are
2880 specified, a `ValueError` is raised.
2881 frame_length : float, optional
2882 Length of each measurement frame in the same units as the `abscissa`
2883 field (`samples_per_frame` = `frame_length`/`self.abscissa_spacing`).
2884 Either this argument or `samples_per_frame` must be specified. If
2885 both or neither are specified, a `ValueError` is raised.
2886 overlap : float, optional
2887 Fraction of the measurement frame to overlap (i.e. 0.25 not 25 to
2888 overlap a quarter of the frame). Either this argument or
2889 `overlap_samples` must be specified. If both are
2890 specified, a `ValueError` is raised. If neither are specified, no
2891 overlap is used.
2892 overlap_samples : int, optional
2893 Number of samples in the measurement frame to overlap. Either this
2894 argument or `overlap_samples` must be specified. If both
2895 are specified, a `ValueError` is raised. If neither are specified,
2896 no overlap is used.
2897 window : str or tuple or array_like, optional
2898 Desired window to use. If window is a string or tuple, it is passed
2899 to `scipy.signal.get_window` to generate the window values, which
2900 are DFT-even by default. See `get_window` for a list of windows and
2901 required parameters. If window is array_like it will be used
2902 directly as the window and its length must be `samples_per_frame`.
2903 If not specified, no window will be applied.
2904 check_cola : bool, optional
2905 If `True`, raise a `ValueError` if the specified overlap and window
2906 function are not compatible with COLA. The default is False.
2907 allow_fractional_frames : bool, optional
2908 If `False` (default), the signal will be split into a number of
2909 full frames, and any remaining fractional frame will be discarded.
2910 This will not allow COLA to be satisfied.
2911 If `True`, fractional frames will be retained and zero padded to
2912 create a full frames.
2914 Returns
2915 -------
2916 TimeHistoryArray
2917 Returns a new TimeHistoryArray with shape [num_frames,...] where
2918 ... is the shape of the original array.
2920 """
2921 # Check to see that the arguments were specified correctly
2922 if samples_per_frame is None and frame_length is None:
2923 raise ValueError('One of `samples_per_frame` or `frame_length` must be specified')
2924 elif samples_per_frame is not None and frame_length is not None:
2925 raise ValueError('`samples_per_frame` can not be specified along with `frame_length`')
2926 if overlap is None and overlap_samples is None:
2927 overlap_samples = 0
2928 elif overlap is not None and overlap_samples is not None:
2929 raise ValueError('`overlap` can not be specified along with `overlap_samples`')
2930 # Compute samples_per_frame and overlap_samples
2931 if samples_per_frame is None:
2932 samples_per_frame = int(np.round(frame_length/self.abscissa_spacing))
2933 # Make sure that we have an even number of samples per frame.
2934 if samples_per_frame % 2 == 1:
2935 raise ValueError('`samples_per_frame` must be an even number')
2936 if overlap_samples is None:
2937 overlap_samples = int(np.round(samples_per_frame*overlap))
2939 # If partial frames, then we will zero pad to make it the right length
2940 if allow_fractional_frames:
2941 self = self.zero_pad(samples_per_frame*2, left=True, right=True)
2942 num_frames = int(np.floor((self.num_elements-overlap_samples)/(samples_per_frame - overlap_samples)))
2943 frame_indices = np.arange(samples_per_frame) + np.arange(num_frames)[:, np.newaxis]*(samples_per_frame-overlap_samples)
2944 # See if we need to truncate empty frames
2945 if allow_fractional_frames:
2946 # Get rid of the first frame which is all zeros
2947 frame_indices = frame_indices[1:]
2948 # See if we need to get rid of the last frame, which could be all
2949 # zeros
2950 if frame_indices[-1, -1] + 1 == self.num_elements:
2951 frame_indices = frame_indices[:-1]
2953 # Put the "frame" axis at the front of the new array so all other parameters
2954 # can broadcast out across measurement frames
2955 new_abscissa = np.moveaxis(self.abscissa[..., frame_indices], -2, 0)
2956 new_ordinate = np.moveaxis(self.ordinate[..., frame_indices], -2, 0)
2958 # Now apply the window
2959 if window is None:
2960 window = 'boxcar'
2961 if isinstance(window, str) or isinstance(window, tuple):
2962 window = get_window(window, samples_per_frame)
2963 try:
2964 new_ordinate *= window
2965 except ValueError:
2966 raise ValueError('Could Not Multiply Window Function (shape {:}) by Ordinate (shape {:})'.format(window.shape, new_ordinate.shape))
2967 if check_cola:
2968 if not sig.check_COLA(window, samples_per_frame, overlap_samples):
2969 raise ValueError('COLA Check Failed: specified window and overlap do not result in a constant overlap-add condition, see scipy.check_COLA for more information')
2971 return data_array(FunctionTypes.TIME_RESPONSE, new_abscissa, new_ordinate,
2972 self.coordinate, self.comment1, self.comment2, self.comment3,
2973 self.comment4, self.comment5)
2975 def mimo_forward(self, transfer_function):
2976 """
2977 Performs the forward mimo calculation via convolution.
2979 Parameters
2980 ----------
2981 transfer_function : TransferFunctionArray or ImpulseResponseFunctionArray
2982 This is the FRFs that will be used in the forward problem. A matrix of IRFs
2983 is prefered, but FRFs can also be used, although the FRFs will be immediately
2984 converted to IRFs.
2986 Raises
2987 ------
2988 ValueError
2989 If the sampling rates for the data and IRFs/FRFs don't match.
2990 ValueError
2991 If the references in the IRFs/FRFs don't match the supplied input
2992 data.
2994 Returns
2995 -------
2996 TimeHistoryArray
2997 Response time histories
2999 """
3000 # Converting FRFs to IRFs, if required
3001 if isinstance(transfer_function, TransferFunctionArray):
3002 transfer_function = transfer_function.ifft()
3004 # Some initial organization
3005 transfer_function = transfer_function.reshape_to_matrix()
3006 reference_dofs = transfer_function.reference_coordinate[0, :]
3007 response_dofs = transfer_function.response_coordinate[:, 0]
3008 self = self[reference_dofs[..., np.newaxis]]
3009 irfs = np.moveaxis(transfer_function.ordinate, -2, 0)
3010 num_references, number_responses, model_order = irfs.shape
3011 signal_length = self.num_elements
3013 # Checking to see if the sampling rates are the same for both data sets
3014 if not np.isclose(self.abscissa_spacing, transfer_function.abscissa_spacing):
3015 raise ValueError('The transfer function sampling rate {:} does not match the time data {:}.'.format(
3016 1/transfer_function.abscissa_spacing,1/self.abscissa_spacing
3017 ))
3019 # Setting up and doing the convolution
3020 convolved_response = np.zeros((number_responses, signal_length), dtype=np.float64)
3021 for reference_irfs, inputs in zip(irfs, self.ordinate):
3022 convolved_response += oaconvolve(reference_irfs, inputs[np.newaxis, :])[:, :signal_length]
3024 return data_array(FunctionTypes.TIME_RESPONSE, self.abscissa[0], convolved_response, response_dofs[..., np.newaxis])
3026 def mimo_inverse(self, transfer_function,
3027 time_method='single_frame',
3028 cola_frame_length=None,
3029 cola_window='hann',
3030 cola_overlap=None,
3031 zero_pad_length=None,
3032 inverse_method='standard',
3033 response_weighting_matrix=None,
3034 reference_weighting_matrix=None,
3035 regularization_weighting_matrix=None,
3036 regularization_parameter=None,
3037 cond_num_threshold=None,
3038 num_retained_values=None,
3039 transfer_function_odd_samples = False):
3040 """
3041 Performs the inverse source estimation for time domain (transient) problems
3042 using Fourier deconvolution. The response nodes used in the inverse source
3043 estimation are the ones contained in the supplied FRF matrix.
3045 Parameters
3046 ----------
3047 transer_function : TransferFunctionArray or ImpulseResponseFunctionArray
3048 This is the FRFs that will be used in the inverse source estimation
3049 time_method : str, optional
3050 The method to used to handle the time data for the inverse source
3051 estimation. The available options are:
3052 - single_frame - this method performs the Fourier deconvolution
3053 via an FFT on a single frame that encompases the entire time
3054 signal.
3055 - COLA - this method performs the Fourier deconvolution via a
3056 series of FFTs on relatively small frames of the time signal
3057 using a "constant overlap and add" method. This method may be
3058 faster than the single_frame method.
3059 cola_frame_length : float, optional
3060 The frame length (in samples) if the COLA method is being used. The
3061 default frame length is Fs/df from the transfer function.
3062 cola_window : str, optional
3063 The desired window for the COLA procedure, must exist in the scipy
3064 window library. The default is a hann window.
3065 cola_overlap : int, optional
3066 The number of overlapping samples between measurement frames in the
3067 COLA procedure. If not specified, a default value of half the
3068 cola_frame_length is used.
3069 zero_pad_length : int, optional
3070 The number of zeros used to pre and post pad the response data, to
3071 avoid convolution wrap-around error. The default is to use the
3072 "determine_zero_pad_length" function to determine the zero_pad_length.
3073 inverse_method : str, optional
3074 The method to be used for the FRF matrix inversions. The available
3075 methods are:
3076 - standard - basic pseudo-inverse via numpy.linalg.pinv with the
3077 default rcond parameter, this is the default method
3078 - threshold - pseudo-inverse via numpy.linalg.pinv with a specified
3079 condition number threshold
3080 - tikhonov - pseudo-inverse using the Tikhonov regularization method
3081 - truncation - pseudo-inverse where a fixed number of singular values
3082 are retained for the inverse
3083 response_weighting_matrix : sdpy.Matrix or np.ndarray, optional
3084 Not currently implemented
3085 reference_weighting_matrix : sdpy.Matrix or np.ndarray, optional
3086 Not currently implemented
3087 regularization_weighting_matrix : sdpy.Matrix, optional
3088 Matrix used to weight input degrees of freedom via Tikhonov regularization.
3089 regularization_parameter : float or np.ndarray, optional
3090 Scaling parameter used on the regularization weighting matrix when the tikhonov
3091 method is chosen. A vector of regularization parameters can be provided so the
3092 regularization is different at each frequency line. The vector must match the
3093 length of the FRF abscissa in this case (either be size [num_lines,] or [num_lines, 1]).
3094 cond_num_threshold : float or np.ndarray, optional
3095 Condition number used for SVD truncation when the threshold method is chosen.
3096 A vector of condition numbers can be provided so it varies as a function of
3097 frequency. The vector must match the length of the FRF abscissa in this case
3098 (either be size [num_lines,] or [num_lines, 1]).
3099 num_retained_values : float or np.ndarray, optional
3100 Number of singular values to retain in the pseudo-inverse when the truncation
3101 method is chosen. A vector of can be provided so the number of retained values
3102 can change as a function of frequency. The vector must match the length of the
3103 FRF abscissa in this case (either be size [num_lines,] or [num_lines, 1]).
3104 transfer_function_odd_samples : bool, optional
3105 If True, then it is assumed that the spectrum has been constructed
3106 from a signal with an odd number of samples. Note that this
3107 function uses the rfft function from scipy to compute the
3108 inverse fast fourier transform. The irfft function is not round-trip
3109 equivalent for odd functions, because by default it assumes an even
3110 signal length. For an odd signal length, the user must either specify
3111 transfer_function_odd_samples = True to make it round-trip equivalent.
3113 Raises
3114 ------
3115 NotImplementedError
3116 If a response weighting matrix is supplied
3117 NotImplementedError
3118 If a reference weighting matrix is supplied
3119 ValueError
3120 If the sampling rates for the data and FRFs don't match.
3121 ValueError
3122 If the number of responses in the FRFs don't match the supplied response
3123 data.
3125 Returns
3126 -------
3127 TimeHistoryArray
3128 Time history array of the estimated sources
3130 Notes
3131 -----
3132 This function computes the time domain inputs required to match the target time traces
3133 using Fourier deconvolution, which is essentially a frequency domain problem. The general
3134 method is to compute the frequency spectrum of the target time traces, then solve the
3135 inverse problem in the time domain using the supplied FRFs (H^+ * X). The inverse of the
3136 FRF matrix is found using the same methods as the mimo_inverse function for the
3137 PowerSpectralDensityArray class. The input spectrum is then converted back to the time
3138 domain via a inverse fourier transform.
3140 The 0 Hz component is explicitly rejected from the FRFs, so the estimated forces cannot
3141 include a 0 Hz component.
3143 References
3144 ----------
3145 .. [1] Wikipedia, "Moore-Penrose inverse".
3146 https://en.wikipedia.org/wiki/Moore%E2%80%93Penrose_inverse
3147 .. [2] A.N. Tithe, D.J. Thompson, The quantification of structure-borne transmission pathsby inverse methods. Part 2: Use of regularization techniques,
3148 Journal of Sound and Vibration, Volume 264, Issue 2, 2003, Pages 433-451, ISSN 0022-460X,
3149 https://doi.org/10.1016/S0022-460X(02)01203-8.
3150 .. [3] Wikipedia, "Ridge regression".
3151 https://en.wikipedia.org/wiki/Ridge_regression
3152 .. [4] Wikipedia, "Overlap-add Method".
3153 https://en.wikipedia.org/wiki/Overlap-add_method
3154 """
3155 # Converting IRFs to FRFs, if required
3156 if isinstance(transfer_function, ImpulseResponseFunctionArray):
3157 transfer_function = transfer_function.fft()
3159 # Initial orginization of the data
3160 indexed_transfer_function = transfer_function.reshape_to_matrix()
3161 response_dofs = indexed_transfer_function[:, 0].response_coordinate
3162 reference_dofs = indexed_transfer_function[0, :].reference_coordinate
3164 indexed_response_data = self[response_dofs[..., np.newaxis]]
3166 indexed_irf = indexed_transfer_function.ifft()
3167 model_order = indexed_irf.num_elements
3169 # Checking to see if the sampling rates are the same for both data sets
3170 fs_inputs = 1/indexed_response_data.abscissa_spacing
3171 fs_frf = indexed_transfer_function.flatten()[0].abscissa[-1]*2
3172 if not np.isclose(fs_frf, fs_inputs):
3173 raise ValueError('The transfer function sampling rate does not match the time data.')
3175 # Preparing the response data and FRFs for the source estimation
3176 if time_method == 'single_frame':
3177 # Zero pad for convolution wrap-around
3178 if zero_pad_length is None:
3179 padded_response = indexed_response_data.zero_pad(2*model_order, left=True, right = True,
3180 use_next_fast_len = True)
3181 else:
3182 padded_response = indexed_response_data.zero_pad(zero_pad_length, left=True, right=True)
3183 actual_zero_pad = padded_response.num_elements - indexed_response_data.num_elements
3184 # Now make the FRFs the same size
3185 modified_frfs = indexed_transfer_function.interpolate_by_zero_pad(padded_response.num_elements,odd_num_samples = transfer_function_odd_samples)
3186 modified_frfs.ordinate[...,0] = 0
3187 padded_frequency_domain_data = padded_response.fft(norm='backward')
3188 irfft_num_samples = padded_response.num_elements
3189 elif time_method == 'cola':
3190 if cola_frame_length is None:
3191 cola_frame_length = int(model_order + model_order % 2) # This is a slightly strange operation to gaurantee an even frame length
3192 if cola_overlap is None:
3193 cola_overlap = cola_frame_length//2
3194 # Split into measurement frames
3195 segmented_data = indexed_response_data.split_into_frames(
3196 samples_per_frame=cola_frame_length,
3197 overlap_samples=cola_overlap,
3198 window=cola_window,
3199 check_cola=True,
3200 allow_fractional_frames=True)
3201 # Zero pad
3202 if zero_pad_length is None:
3203 zero_padded_data = segmented_data.zero_pad(
3204 2*model_order, left=True, right=True, use_next_fast_len=True)
3205 else:
3206 zero_padded_data = segmented_data.zero_pad(
3207 zero_pad_length, left=True, right=True)
3208 actual_zero_pad = zero_padded_data.num_elements - segmented_data.num_elements
3209 modified_frfs = indexed_transfer_function.interpolate_by_zero_pad(zero_padded_data.num_elements,odd_num_samples = transfer_function_odd_samples)
3210 modified_frfs.ordinate[...,0] = 0
3211 padded_frequency_domain_data = zero_padded_data.fft(norm='backward')
3212 irfft_num_samples = zero_padded_data.num_elements
3213 else:
3214 raise NameError('The selected time method is not available')
3216 # Need to interpolate the conditioning parameters to match the length of the padded
3217 # FRFs
3218 if cond_num_threshold is not None:
3219 cond_num_threshold = np.asarray(cond_num_threshold, dtype=np.float64)
3220 if cond_num_threshold.size > 1:
3221 cond_num_threshold = interp1d(
3222 indexed_transfer_function[0, 0].abscissa,
3223 cond_num_threshold,
3224 'linear',
3225 bounds_error=False,
3226 fill_value=(cond_num_threshold[0], cond_num_threshold[-1]),
3227 assume_sorted=True)(modified_frfs[0, 0].abscissa)
3228 if num_retained_values is not None:
3229 num_retained_values = np.asarray(num_retained_values, dtype=np.intc)
3230 if num_retained_values.size > 1:
3231 num_retained_values = interp1d(
3232 indexed_transfer_function[0, 0].abscissa,
3233 num_retained_values,
3234 'previous',
3235 bounds_error=False,
3236 fill_value=(num_retained_values[0], num_retained_values[-1]),
3237 assume_sorted=True)(modified_frfs[0, 0].abscissa)
3238 if regularization_parameter is not None:
3239 regularization_parameter = np.asarray(regularization_parameter, dtype=np.float64)
3240 if regularization_parameter.size > 1:
3241 regularization_parameter = interp1d(
3242 indexed_transfer_function[0, 0].abscissa,
3243 regularization_parameter,
3244 'linear',
3245 bounds_error=False,
3246 fill_value=(regularization_parameter[0], regularization_parameter[-1]),
3247 assume_sorted=True)(modified_frfs[0, 0].abscissa)
3249 # Set up weighting matrices
3250 if response_weighting_matrix is not None:
3251 raise NotImplementedError('Response weighting has not been implemented yet')
3252 if reference_weighting_matrix is not None:
3253 raise NotImplementedError('Reference weighting has not been implemented yet')
3254 if regularization_weighting_matrix is not None:
3255 regularization_weighting_matrix = regularization_weighting_matrix[reference_dofs, reference_dofs].matrix
3257 # Now solve the inverse problem
3258 frf_pinv = frf_inverse(np.moveaxis(modified_frfs.ordinate, -1, 0),
3259 method=inverse_method,
3260 response_weighting_matrix=response_weighting_matrix,
3261 reference_weighting_matrix=reference_weighting_matrix,
3262 regularization_weighting_matrix=regularization_weighting_matrix,
3263 regularization_parameter=regularization_parameter,
3264 cond_num_threshold=cond_num_threshold,
3265 num_retained_values=num_retained_values)
3266 method_statement_start = 'The FRFs are being inverted using the '
3267 method_statement_end = ' method'
3268 print(method_statement_start+inverse_method+method_statement_end)
3270 # Get the first modified frequency line above the original starting point of the FRF
3271 inverse_start_index = np.argmax(modified_frfs[0, 0].abscissa >= indexed_transfer_function[0, 0].abscissa[0])
3273 # Doing the source estimation
3274 padded_frequency_domain_data = np.moveaxis(padded_frequency_domain_data.ordinate, -1, -2)[..., np.newaxis]
3275 forces_frequency_domain = frf_pinv@padded_frequency_domain_data
3276 forces_frequency_domain[..., :inverse_start_index, :, 0] = 0
3277 forces_time_domain_with_padding = scipyfft.irfft(forces_frequency_domain[..., 0], n = irfft_num_samples, axis=-2, norm='backward')
3278 # Compute the zero padding used
3279 pre_pad_length = actual_zero_pad//2
3280 post_pad_length = actual_zero_pad - pre_pad_length
3281 if time_method == 'single_frame':
3282 forces_time_domain = forces_time_domain_with_padding[pre_pad_length:-post_pad_length, :]
3283 return_val = data_array(FunctionTypes.TIME_RESPONSE, indexed_response_data[0].abscissa, np.moveaxis(forces_time_domain, 0, -1),
3284 reference_dofs[..., np.newaxis])
3285 elif time_method == 'cola':
3286 forces_time_domain_with_padding = data_array(
3287 FunctionTypes.TIME_RESPONSE,
3288 zero_padded_data.abscissa[..., :1, :],
3289 np.moveaxis(forces_time_domain_with_padding, -1, -2),
3290 reference_dofs[:, np.newaxis])
3292 # Assemble the COLA
3293 forces_time_domain_with_padding = TimeHistoryArray.overlap_and_add(
3294 forces_time_domain_with_padding,
3295 overlap_samples=actual_zero_pad + cola_overlap)
3296 start_index = cola_frame_length - cola_overlap + pre_pad_length
3297 end_index = start_index + indexed_response_data.num_elements
3298 return_val = forces_time_domain_with_padding.idx_by_el[start_index:end_index]
3300 # Compute COLA weighting
3301 window_fn = get_window(cola_window, cola_frame_length)
3302 step = cola_frame_length - cola_overlap
3303 weighting = np.median(sum(window_fn[ii*step:(ii+1)*step] for ii in range(cola_frame_length//step)))
3304 return_val = return_val / weighting
3306 return return_val
3308 def rms(self):
3309 return np.sqrt(np.mean(self.ordinate**2, axis=-1))
3311 def to_rattlesnake_specification(self, filename=None,
3312 coordinate_order=None,
3313 min_time=None,
3314 max_time=None):
3315 if coordinate_order is not None:
3316 if coordinate_order.ndim == 1:
3317 coordinate_order = coordinate_order[:, np.newaxis]
3318 reshaped_data = self[coordinate_order]
3319 else:
3320 reshaped_data = self
3321 if min_time is not None or max_time is not None:
3322 if min_time is None:
3323 min_time = -np.inf
3324 if max_time is None:
3325 max_time = np.inf
3326 reshaped_data = reshaped_data.extract_elements_by_abscissa(min_time, max_time)
3327 out_dict = dict(t=reshaped_data[0].abscissa - reshaped_data[0].abscissa[0],
3328 signal=reshaped_data.ordinate,
3329 coordinate=reshaped_data.coordinate.view(np.ndarray))
3330 if filename is not None:
3331 np.savez(filename, **out_dict)
3332 return out_dict
3334 def find_signal_shift(self, other_signal,
3335 compute_subsample_shift=True,
3336 good_line_threshold=0.01):
3337 """
3338 Computes the shift between two sets of time signals
3340 This is the amount that `other_signal` leads `self`. If the time shift
3341 is positive, it means that features in `other_signal` occur earlier in
3342 time compared to `self`. If the time shift is negative, it means that
3343 features in `other_signal` occur later in time compared to `self`.
3345 To align two signals, you can take the time shift from this function and
3346 pass it into the `shift_signal` method of `other_signal`.
3348 Parameters
3349 ----------
3350 other_signal : TimeHistoryArray
3351 The signal against which this signal should be compared in time.
3352 It should have the same coordinate ordering and the same number of
3353 abscissa as this signal.
3354 compute_subsample_shift : bool, optional
3355 If False, this function will simply align to the nearest sample.
3356 If True, this function will attempt to use FFT phases to compute a
3357 subsample shift between the signals. Default is True.
3358 good_line_threshold : float, optional
3359 Threshold to use to compute "good" frequency lines. This function
3360 uses phase to compute subsample shifts. If there are frequency
3361 lines without content, they should be ignored. Frequency lines less
3362 than `good_line_threshold` times the maximum of the spectra are
3363 ignored. The default is 0.01.
3365 Returns
3366 -------
3367 time_shift : float
3368 The time difference between the two signals.
3370 """
3371 this_fft = self.fft()
3373 this_ordinate = this_fft.ordinate
3374 other_ordinate = other_signal.fft().ordinate
3376 correlation = scipyfft.irfft(this_ordinate*other_ordinate.conj())
3377 time_shift_indices = int(np.mean(np.argmax(correlation, axis=-1)))
3379 # Roll the arrays to get them to align
3380 shifted_signal = other_signal.copy()
3381 shifted_signal.ordinate = np.roll(other_signal.ordinate, time_shift_indices, axis=-1)
3383 dt = np.mean(np.diff(self.abscissa, axis=-1))
3384 time_shift = dt*time_shift_indices
3386 if compute_subsample_shift:
3387 # Now compute the subsample shift
3388 shifted_ordinate = shifted_signal.fft().ordinate
3390 # Only compute at frequency lines where there's signal
3391 good_lines = np.abs(shifted_ordinate)/np.max(np.abs(shifted_ordinate), axis=-1, keepdims=True) > good_line_threshold
3392 good_lines[..., 0] = False
3394 phase_difference = np.angle(this_ordinate/shifted_ordinate)
3396 phase_slope = np.median(phase_difference[good_lines]/this_fft.abscissa[good_lines])
3398 time_shift -= phase_slope/(2*np.pi)
3400 # Wrap so it's negative if that's the smaller distance
3401 if time_shift > dt*self.num_elements/2:
3402 time_shift -= dt*self.num_elements
3404 return time_shift
3406 def shift_signal(self, time_shift):
3407 """
3408 Shift a signal in time by a specified amount.
3410 Utilizes the FFT shift theorem to move a signal in time.
3412 Parameters
3413 ----------
3414 time_shift : float
3415 The time shift to apply to the signal. A negative value will cause
3416 features to occur earlier in time. A positive value will cause
3417 features to occur later in time.
3419 Returns
3420 -------
3421 shifted_signal : TimeHistoryArray
3422 A shifted version of the original signal.
3424 """
3425 phase_shift_slope = -time_shift*2*np.pi
3426 signal_fft = self.fft()
3428 signal_fft.ordinate *= np.exp(1j*phase_shift_slope*signal_fft.flatten()[0].abscissa)
3430 shifted_signal = signal_fft.ifft()
3432 return shifted_signal
3434 @staticmethod
3435 def overlap_and_add(functions_to_overlap, overlap_samples):
3436 """
3437 Creates a time history by overlapping and adding other time histories.
3439 Parameters
3440 ----------
3441 functions_to_overlap : TimeHistoryArray or list of TimeHistoryArray
3442 A set of TimeHistoryArrays to overlap and add together. If a single
3443 TimeHistoryArray is specified, then the first dimension will be used
3444 to split the signal into segments. All TimeHistoryArrays must have
3445 the same shape and metadata, but need not have the same number of
3446 elements.
3447 overlap_samples : int
3448 Number of samples to overlap the segments as they are added together
3451 Returns
3452 -------
3453 TimeHistoryArray
3454 A TimeHistoryArray consisting of the signals overlapped and added
3455 together.
3457 Notes
3458 -----
3459 All metadata is taken from the first signal. No checks are performed to
3460 make sure that the subsequent functions have common coordinates or
3461 abscissa spacing.
3462 """
3463 # First compute the final length of the signal
3464 num_samples = functions_to_overlap[0].num_elements
3465 for signal in functions_to_overlap[1:]:
3466 num_samples += signal.num_elements-overlap_samples
3467 # Set up the ordinate
3468 ordinate = np.zeros(functions_to_overlap[0].shape+(num_samples,))
3469 # Go through each frame and add it to the function
3470 starting_index = 0
3471 for signal in functions_to_overlap:
3472 ordinate[..., starting_index:starting_index+signal.num_elements] += signal.ordinate
3473 starting_index += signal.num_elements - overlap_samples
3474 # Now set up the rest of the metadata
3475 abscissa = functions_to_overlap[0].abscissa_spacing*np.arange(num_samples) + functions_to_overlap[0].abscissa.min()
3476 return data_array(FunctionTypes.TIME_RESPONSE, abscissa, ordinate,
3477 functions_to_overlap[0].coordinate)
3479 def remove_rigid_body_motion(self, geometry):
3480 """
3481 Removes rigid body displacements from time data.
3483 This function assumes the current TimeHistoryArray is a displacement
3484 signal and adds it to the geometry to create node positions over time,
3485 then it fits a rigid coordinate transformation to each time step and
3486 subtracts off that portion of the motion from the displacement signal.
3488 Parameters
3489 ----------
3490 geometry : Geometry
3491 Geometry with which the node positions are computed
3493 Returns
3494 -------
3495 TimeHistoryArray
3496 A TimeHistoryArray with the rigid body component of motion removed
3498 """
3499 nodes = np.unique(self.coordinate.node)
3500 dofs = coordinate_array(nodes[:, np.newaxis], [1, 2, 3])
3501 sorted_self = self[dofs[..., np.newaxis]]
3502 displacements = sorted_self.ordinate
3503 abscissa = sorted_self.abscissa
3504 starting_positions = geometry.node(nodes).coordinate[..., np.newaxis]
3505 positions_over_time = displacements + starting_positions
3506 # Rearrange indices to match the rigid transformation code
3507 y = np.transpose(positions_over_time, [2, 1, 0])
3508 x = np.transpose(starting_positions, [2, 1, 0])
3509 R, t = lstsq_rigid_transform(x, y)
3510 y_rigid = R@x+t
3511 nonrigid_displacements = data_array(
3512 FunctionTypes.TIME_RESPONSE,
3513 abscissa,
3514 np.transpose(y - y_rigid, [2, 1, 0]),
3515 dofs[..., np.newaxis],
3516 sorted_self.comment1,
3517 sorted_self.comment2,
3518 sorted_self.comment3,
3519 sorted_self.comment4,
3520 sorted_self.comment5)
3521 return nonrigid_displacements[self.coordinate]
3523 def stft(self, samples_per_frame=None, frame_length=None,
3524 overlap=None, overlap_samples=None, window=None,
3525 check_cola=False, allow_fractional_frames=False,
3526 norm='backward'):
3527 """
3528 Computes a Short-Time Fourier Transform (STFT)
3530 The time history is split up into frames with specified length and
3531 computes the spectra for each frame.
3533 Parameters
3534 ----------
3535 samples_per_frame : int, optional
3536 Number of samples in each measurement frame. Either this argument
3537 or `frame_length` must be specified. If both or neither are
3538 specified, a `ValueError` is raised.
3539 frame_length : float, optional
3540 Length of each measurement frame in the same units as the `abscissa`
3541 field (`samples_per_frame` = `frame_length`/`self.abscissa_spacing`).
3542 Either this argument or `samples_per_frame` must be specified. If
3543 both or neither are specified, a `ValueError` is raised.
3544 overlap : float, optional
3545 Fraction of the measurement frame to overlap (i.e. 0.25 not 25 to
3546 overlap a quarter of the frame). Either this argument or
3547 `overlap_samples` must be specified. If both are
3548 specified, a `ValueError` is raised. If neither are specified, no
3549 overlap is used.
3550 overlap_samples : int, optional
3551 Number of samples in the measurement frame to overlap. Either this
3552 argument or `overlap_samples` must be specified. If both
3553 are specified, a `ValueError` is raised. If neither are specified,
3554 no overlap is used.
3555 window : str or tuple or array_like, optional
3556 Desired window to use. If window is a string or tuple, it is passed
3557 to `scipy.signal.get_window` to generate the window values, which
3558 are DFT-even by default. See `get_window` for a list of windows and
3559 required parameters. If window is array_like it will be used
3560 directly as the window and its length must be `samples_per_frame`.
3561 If not specified, no window will be applied.
3562 check_cola : bool, optional
3563 If `True`, raise a `ValueError` if the specified overlap and window
3564 function are not compatible with COLA. The default is False.
3565 allow_fractional_frames : bool, optional
3566 If `False` (default), the signal will be split into a number of
3567 full frames, and any remaining fractional frame will be discarded.
3568 This will not allow COLA to be satisfied.
3569 If `True`, fractional frames will be retained and zero padded to
3570 create a full frames.
3572 Returns
3573 -------
3574 frame_abscissa : np.ndarray
3575 The abscissa values at the center of each of the STFT frames
3576 stft : SpectrumArray
3577 A spectrum array with the first axis corresponding to the time
3578 values in `frame_abscissa`.
3579 """
3580 split_frames = self.split_into_frames(
3581 samples_per_frame, frame_length,
3582 overlap, overlap_samples, window,
3583 check_cola, allow_fractional_frames)
3584 frame_abscissa = np.median(split_frames.abscissa, axis=-1)
3585 stft = split_frames.fft(norm=norm)
3586 return frame_abscissa, stft
3588 def upsample(self, factor):
3589 """
3590 Upsamples a time history using frequency domain zero padding.
3592 Parameters
3593 ----------
3594 factor : int
3595 The upsample factor.
3597 Returns
3598 -------
3599 TimeHistoryArray
3600 A time history with a sample rate that is factor larger than the
3601 original signal
3603 """
3604 fft = self.fft()
3605 fft_zp = fft.zero_pad(fft.num_elements*(factor-1))
3606 return fft_zp.ifft()*factor
3608 def resample(self, num_samples):
3609 """
3610 Uses Scipy.signal.resample to resample the time history array
3612 Parameters
3613 ----------
3614 num_samples : int
3615 The number of samples in the desired signal.
3617 Returns
3618 -------
3619 TimeHistoryArray
3620 A TimeHistoryArray object with resampled abscissa and ordinate
3622 """
3623 xr, tr = sig.resample(self.ordinate, num_samples, self.abscissa, axis=-1)
3624 return time_history_array(
3625 tr,
3626 xr,
3627 self.coordinate,
3628 self.comment1,
3629 self.comment2,
3630 self.comment3,
3631 self.comment4,
3632 self.comment5,
3633 )
3635 def apply_transformation(self, transformation, invert_transformation=False):
3636 """
3637 Applies a transformations to the time traces.
3639 Parameters
3640 ----------
3641 transformation : Matrix
3642 The transformation to apply to the time traces. It should be a
3643 SDynPy matrix object with the "transformed" coordinates on the
3644 rows and the "physical" coordinates on the columns. The matrix
3645 can only be be 2D.
3646 invert_reference_transformation : bool, optional
3647 Whether or not to invert the transformation when applying it to
3648 the time traces. The default is False, which is standard practice.
3649 The row/column ordering in the transformation should be flipped
3650 if this is set to true.
3652 Raises
3653 ------
3654 ValueError
3655 If the transformation array has more than two dimensions.
3656 ValueError
3657 If the physical degrees of freedom in the transformation does not
3658 match the spectra.
3660 Returns
3661 -------
3662 transformed_data : TimeHistoryArray
3663 The time traces with the transformations applied.
3664 """
3665 if not self.validate_common_abscissa():
3666 raise ValueError('The abscissa must be consistent accross all functions in the NDDataArray')
3668 physical_coordinate = np.unique(self.response_coordinate)
3669 original_data_ordinate = np.moveaxis(self[physical_coordinate[...,np.newaxis]].ordinate, -1, 0)[..., np.newaxis]
3671 if invert_transformation:
3672 if not np.all(np.unique(transformation.row_coordinate) == physical_coordinate):
3673 raise ValueError('The physical coordinates in the transformation do no match the spectra')
3674 transformed_coordinate = np.unique(transformation.column_coordinate)
3675 transformation_matrix = np.linalg.pinv(transformation[physical_coordinate, transformed_coordinate])
3676 elif not invert_transformation:
3677 if not np.all(np.unique(transformation.column_coordinate) == physical_coordinate):
3678 raise ValueError('The physical coordinates in the transformation do no match the spectra')
3679 transformed_coordinate = np.unique(transformation.row_coordinate)
3680 transformation_matrix = transformation[transformed_coordinate, physical_coordinate]
3682 if transformation_matrix.ndim != 2:
3683 raise ValueError('The transformation array must be two dimensional')
3685 transformed_data_ordinate = (transformation_matrix @ original_data_ordinate)[...,0]
3687 return data_array(FunctionTypes.TIME_RESPONSE, self.ravel().abscissa[0], np.moveaxis(transformed_data_ordinate, 0, -1),
3688 transformed_coordinate[...,np.newaxis])
3690 @classmethod
3691 def pseudorandom_signal(cls, dt, signal_length, coordinates,
3692 min_frequency=None, max_frequency=None,
3693 signal_rms=1, frames=1, frequency_shape=None,
3694 different_realizations=False,
3695 comment1='', comment2='', comment3='',
3696 comment4='', comment5=''):
3697 """
3698 Generates a pseudorandom signal at the specified coordinates
3700 Parameters
3701 ----------
3702 dt : float
3703 Abscissa spacing in the final signal.
3704 signal_length : int
3705 Number of samples in the signal
3706 coordinates : CoordinateArray
3707 Coordinate array used to generate the signal. If the last dimension
3708 of coordinates is not shape 1, then a new axis will be added to make
3709 it shape 1. The shape of the resulting TimeHistoryArray will be
3710 determined by the shape of the input coordinates.
3711 min_frequency : float, optional
3712 Minimum frequency content in the signal. The default is the lowest
3713 nonzero frequency line.
3714 max_frequency : float, optional
3715 Maximum frequency content in the signal. The default is the highest
3716 frequency content in the signal, e.g. the Nyquist frequency.
3717 signal_rms : float or np.ndarray, optional
3718 RMS value for the generated signals. The default is 1. The shape of
3719 this value should be broadcastable with the size of the
3720 generated TimeHistoryArray if different RMS values are desired for
3721 each signal.
3722 frames : int, optional
3723 Number of frames to generate. These will essentially be repeats of
3724 the first frame for the number of frames specified. The default is 1.
3725 frequency_shape : function, optional
3726 An optional function that should accept a frequency value and return
3727 an amplitude at that frequency. The default is constant scaling
3728 across all frequency lines.
3729 different_realizations : bool
3730 An optional argument that specifies whether or not different
3731 functions should have different realizations of the pseudorandom
3732 signal, or if they should all be identical.
3733 comment1 : np.ndarray, optional
3734 Comment used to describe the data in the data array. The default is ''.
3735 comment2 : np.ndarray, optional
3736 Comment used to describe the data in the data array. The default is ''.
3737 comment3 : np.ndarray, optional
3738 Comment used to describe the data in the data array. The default is ''.
3739 comment4 : np.ndarray, optional
3740 Comment used to describe the data in the data array. The default is ''.
3741 comment5 : np.ndarray, optional
3742 Comment used to describe the data in the data array. The default is ''.
3744 Returns
3745 -------
3746 TimeHistoryArray :
3747 A time history containing the specified pseudorandom signal
3749 """
3750 # Compute signal processing parameters
3751 total_frame_length = dt*signal_length
3752 df = 1/total_frame_length
3753 f_nyquist = 1/(2*dt)
3754 fft_lines = f_nyquist/df
3755 if frequency_shape is None:
3756 frequency_shape = _flat_frequency_shape
3757 if np.array(coordinates).dtype.type is np.str_:
3758 coordinates = coordinate_array(string_array=coordinates)
3759 # Get coordinate size
3760 if coordinates.ndim == 0 or coordinates.shape[-1] != 1:
3761 coordinates = coordinates[..., np.newaxis]
3762 ordinate = np.empty(coordinates.shape[:-1]+(signal_length*frames,))
3763 if different_realizations:
3764 for index in np.ndindex(coordinates.shape[:-1]):
3765 ordinate[index] = pseudorandom(fft_lines, f_nyquist,
3766 min_freq=min_frequency,
3767 max_freq=max_frequency,
3768 averages=frames,
3769 shape_function=frequency_shape)[1]
3770 else:
3771 ordinate[...] = pseudorandom(fft_lines, f_nyquist,
3772 min_freq=min_frequency,
3773 max_freq=max_frequency,
3774 averages=frames,
3775 shape_function=frequency_shape)[1]
3776 # Apply the RMS
3777 current_rms = np.sqrt(np.mean(ordinate**2, axis=-1))
3778 ordinate *= np.array(signal_rms)[..., np.newaxis]/current_rms[..., np.newaxis]
3779 # Now create the object
3780 abscissa = dt * np.arange(signal_length*frames)
3781 time_history = data_array(FunctionTypes.TIME_RESPONSE,
3782 abscissa,
3783 ordinate,
3784 coordinates,
3785 comment1, comment2, comment3, comment4, comment5)
3786 return time_history
3788 @classmethod
3789 def random_signal(cls, dt, signal_length, coordinates,
3790 min_frequency=None, max_frequency=None,
3791 signal_rms=1, frames=1, frequency_shape=None,
3792 comment1='', comment2='', comment3='',
3793 comment4='', comment5=''):
3794 """
3795 Generates a random signal with the specified parameters
3797 Parameters
3798 ----------
3799 dt : float
3800 Abscissa spacing in the final signal.
3801 signal_length : int
3802 Number of samples in the signal
3803 coordinates : CoordinateArray
3804 Coordinate array used to generate the signal. If the last dimension
3805 of coordinates is not shape 1, then a new axis will be added to make
3806 it shape 1. The shape of the resulting TimeHistoryArray will be
3807 determined by the shape of the input coordinates.
3808 min_frequency : float, optional
3809 Minimum frequency content in the signal. The default is the lowest
3810 nonzero frequency line.
3811 max_frequency : float, optional
3812 Maximum frequency content in the signal. The default is the highest
3813 frequency content in the signal, e.g. the Nyquist frequency.
3814 signal_rms : float or np.ndarray, optional
3815 RMS value for the generated signals. The default is 1. The shape of
3816 this value should be broadcastable with the size of the
3817 generated TimeHistoryArray if different RMS values are desired for
3818 each signal.
3819 frames : int, optional
3820 Number of frames to generate. These will essentially be repeats of
3821 the first frame for the number of frames specified. The default is 1.
3822 frequency_shape : function, optional
3823 An optional function that should accept a frequency value and return
3824 an amplitude at that frequency. The default is constant scaling
3825 across all frequency lines.
3826 comment1 : np.ndarray, optional
3827 Comment used to describe the data in the data array. The default is ''.
3828 comment2 : np.ndarray, optional
3829 Comment used to describe the data in the data array. The default is ''.
3830 comment3 : np.ndarray, optional
3831 Comment used to describe the data in the data array. The default is ''.
3832 comment4 : np.ndarray, optional
3833 Comment used to describe the data in the data array. The default is ''.
3834 comment5 : np.ndarray, optional
3835 Comment used to describe the data in the data array. The default is ''.
3837 Returns
3838 -------
3839 TimeHistoryArray :
3840 A time history containing the specified random signal
3841 """
3842 if np.array(coordinates).dtype.type is np.str_:
3843 coordinates = coordinate_array(string_array=coordinates)
3844 # Get coordinate size
3845 if coordinates.ndim == 0 or coordinates.shape[-1] != 1:
3846 coordinates = coordinates[..., np.newaxis]
3847 ordinate = np.random.randn(*coordinates.shape[:-1], signal_length*frames)
3848 if (min_frequency is not None or max_frequency is not None or frequency_shape is not None):
3849 if frequency_shape is None:
3850 frequency_shape = _flat_frequency_shape
3851 frequencies = scipyfft.rfftfreq(signal_length*frames, dt)
3852 fft = scipyfft.rfft(ordinate, axis=-1)
3853 for index, frequency in enumerate(frequencies):
3854 if min_frequency is not None and frequency < min_frequency:
3855 fft[..., index] = 0
3856 elif max_frequency is not None and frequency > max_frequency:
3857 fft[..., index] = 0
3858 else:
3859 fft[..., index] *= frequency_shape(frequency)
3860 ordinate = scipyfft.irfft(fft, n = signal_length*frames, axis=-1)
3861 # Now set RMS
3862 current_rms = np.sqrt(np.mean(ordinate**2, axis=-1))
3863 ordinate *= np.array(signal_rms)[..., np.newaxis]/current_rms[..., np.newaxis]
3864 # Now create the object
3865 abscissa = dt * np.arange(signal_length*frames)
3866 time_history = data_array(FunctionTypes.TIME_RESPONSE,
3867 abscissa,
3868 ordinate,
3869 coordinates,
3870 comment1, comment2, comment3, comment4, comment5)
3871 return time_history
3873 @classmethod
3874 def sine_signal(cls, dt, signal_length, coordinates,
3875 frequency, amplitude=1, phase=0,
3876 comment1='', comment2='', comment3='',
3877 comment4='', comment5=''):
3878 """
3879 Creates a sinusoidal signal with the specified parameters
3881 Parameters
3882 ----------
3883 dt : float
3884 Abscissa spacing in the final signal.
3885 signal_length : int
3886 Number of samples in the signal
3887 coordinates : CoordinateArray
3888 Coordinate array used to generate the signal. If the last dimension
3889 of coordinates is not shape 1, then a new axis will be added to make
3890 it shape 1. The shape of the resulting TimeHistoryArray will be
3891 determined by the shape of the input coordinates.
3892 frequency : float or np.ndarray
3893 Frequency of signal that will be generated. If multiple frequencies
3894 are specified, they must broadcast with the final size of the
3895 TimeHistoryArray.
3896 amplitude : TYPE, optional
3897 Amplitude of signal that will be generated. If multiple amplitudes
3898 are specified, they must broadcast with the final size of the
3899 TimeHistoryArray. The default is 1.
3900 phase : TYPE, optional
3901 Phase of signal that will be generated. If multiple phases
3902 are specified, they must broadcast with the final size of the
3903 TimeHistoryArray.. The default is 0.
3904 comment1 : np.ndarray, optional
3905 Comment used to describe the data in the data array. The default is ''.
3906 comment2 : np.ndarray, optional
3907 Comment used to describe the data in the data array. The default is ''.
3908 comment3 : np.ndarray, optional
3909 Comment used to describe the data in the data array. The default is ''.
3910 comment4 : np.ndarray, optional
3911 Comment used to describe the data in the data array. The default is ''.
3912 comment5 : np.ndarray, optional
3913 Comment used to describe the data in the data array. The default is ''.
3915 Returns
3916 -------
3917 TimeHistoryArray :
3918 A time history containing the specified sine signal
3920 """
3921 if np.array(coordinates).dtype.type is np.str_:
3922 coordinates = coordinate_array(string_array=coordinates)
3923 # Get coordinate size
3924 if coordinates.ndim == 0 or coordinates.shape[-1] != 1:
3925 coordinates = coordinates[..., np.newaxis]
3926 ordinate = np.empty(coordinates.shape[:-1]+(signal_length,))
3927 ordinate[...] = sine(frequency, dt, signal_length, amplitude, phase)
3928 # Now create the object
3929 abscissa = dt * np.arange(signal_length)
3930 time_history = data_array(FunctionTypes.TIME_RESPONSE,
3931 abscissa,
3932 ordinate,
3933 coordinates,
3934 comment1, comment2, comment3, comment4, comment5)
3935 return time_history
3937 @classmethod
3938 def burst_random_signal(cls, dt, signal_length, coordinates,
3939 min_frequency=None, max_frequency=None,
3940 signal_rms=1, frames=1, frequency_shape=None,
3941 on_fraction=0.5, delay_fraction=0.0,
3942 ramp_fraction=0.05,
3943 comment1='', comment2='', comment3='',
3944 comment4='', comment5=''):
3945 """
3946 Generates a burst random signal with the specified parameters
3948 Parameters
3949 ----------
3950 dt : float
3951 Abscissa spacing in the final signal.
3952 signal_length : int
3953 Number of samples in the signal
3954 coordinates : CoordinateArray
3955 Coordinate array used to generate the signal. If the last dimension
3956 of coordinates is not shape 1, then a new axis will be added to make
3957 it shape 1. The shape of the resulting TimeHistoryArray will be
3958 determined by the shape of the input coordinates.
3959 min_frequency : float, optional
3960 Minimum frequency content in the signal. The default is the lowest
3961 nonzero frequency line.
3962 max_frequency : float, optional
3963 Maximum frequency content in the signal. The default is the highest
3964 frequency content in the signal, e.g. the Nyquist frequency.
3965 signal_rms : float or np.ndarray, optional
3966 RMS value for the generated signals. The default is 1. The shape of
3967 this value should be broadcastable with the size of the
3968 generated TimeHistoryArray if different RMS values are desired for
3969 each signal. Note that the RMS will be computed for the "burst"
3970 part of the signal and not include the zero portion of the signal.
3971 frames : int, optional
3972 Number of frames to generate. These will essentially be repeats of
3973 the first frame for the number of frames specified. The default is 1.
3974 frequency_shape : function, optional
3975 An optional function that should accept a frequency value and return
3976 an amplitude at that frequency. The default is constant scaling
3977 across all frequency lines.
3978 on_fraction : float, optional
3979 The fraction of the frame that the signal is active, default is 0.5.
3980 This portion includes the ramp_fraction, so an on_fraction of 0.5 with
3981 a ramp_fraction of 0.05 will be at full level for 0.5-2*0.05 = 0.4
3982 fraction of the full measurement frame.
3983 delay_fraction : float, optional
3984 The fraction of the frame that is empty before the signal starts,
3985 default is 0.0
3986 ramp_fraction : float, optional
3987 The fraction of the frame that is used to ramp between the off
3988 and active signal
3989 comment1 : np.ndarray, optional
3990 Comment used to describe the data in the data array. The default is ''.
3991 comment2 : np.ndarray, optional
3992 Comment used to describe the data in the data array. The default is ''.
3993 comment3 : np.ndarray, optional
3994 Comment used to describe the data in the data array. The default is ''.
3995 comment4 : np.ndarray, optional
3996 Comment used to describe the data in the data array. The default is ''.
3997 comment5 : np.ndarray, optional
3998 Comment used to describe the data in the data array. The default is ''.
4000 Returns
4001 -------
4002 TimeHistoryArray :
4003 A time history containing the specified burst random signal
4004 """
4005 if np.array(coordinates).dtype.type is np.str_:
4006 coordinates = coordinate_array(string_array=coordinates)
4007 # Get coordinate size
4008 if coordinates.ndim == 0 or coordinates.shape[-1] != 1:
4009 coordinates = coordinates[..., np.newaxis]
4010 ordinate = np.random.randn(*coordinates.shape[:-1], signal_length*frames)
4011 if (min_frequency is not None or max_frequency is not None or frequency_shape is not None):
4012 if frequency_shape is None:
4013 frequency_shape = _flat_frequency_shape
4014 frequencies = scipyfft.rfftfreq(signal_length*frames, dt)
4015 fft = scipyfft.rfft(ordinate, axis=-1)
4016 for index, frequency in enumerate(frequencies):
4017 if min_frequency is not None and frequency < min_frequency:
4018 fft[..., index] = 0
4019 elif max_frequency is not None and frequency > max_frequency:
4020 fft[..., index] = 0
4021 else:
4022 fft[..., index] *= frequency_shape(frequency)
4023 ordinate = scipyfft.irfft(fft, n = signal_length*frames, axis=-1)
4024 # Now set RMS
4025 current_rms = np.sqrt(np.mean(ordinate**2, axis=-1))
4026 ordinate *= np.array(signal_rms)[..., np.newaxis]/current_rms[..., np.newaxis]
4027 # Apply the window
4028 delay_samples = int(delay_fraction * signal_length)
4029 ramp_samples = int(ramp_fraction * signal_length)
4030 on_samples = int(on_fraction * signal_length)
4031 burst_window = np.zeros(coordinates.shape[:-1]+(signal_length,))
4032 burst_window[..., delay_samples:delay_samples +
4033 on_samples] = ramp_envelope(on_samples, ramp_samples)
4034 burst_window = np.tile(burst_window, frames)
4035 ordinate *= burst_window
4036 # Now create the object
4037 abscissa = dt * np.arange(signal_length*frames)
4038 time_history = data_array(FunctionTypes.TIME_RESPONSE,
4039 abscissa,
4040 ordinate,
4041 coordinates,
4042 comment1, comment2, comment3, comment4, comment5)
4043 return time_history
4045 @classmethod
4046 def chirp_signal(cls, dt, signal_length, coordinates,
4047 start_frequency=None, end_frequency=None,
4048 frames=1, amplitude_function=None,
4049 force_integer_cycles=True,
4050 comment1='', comment2='', comment3='',
4051 comment4='', comment5=''):
4052 """
4053 Creates a chirp (sine sweep) signal with the specified parameters
4055 Parameters
4056 ----------
4057 dt : float
4058 Abscissa spacing in the final signal.
4059 signal_length : int
4060 Number of samples in the signal
4061 coordinates : CoordinateArray
4062 Coordinate array used to generate the signal. If the last dimension
4063 of coordinates is not shape 1, then a new axis will be added to make
4064 it shape 1. The shape of the resulting TimeHistoryArray will be
4065 determined by the shape of the input coordinates.
4066 start_frequency : TYPE, optional
4067 Starting frequency content in the signal. The default is the lowest
4068 nonzero frequency line.
4069 end_frequency : TYPE, optional
4070 Stopping frequency content in the signal. The default is the highest
4071 non-nyquist frequency line.
4072 frames : int, optional
4073 Number of frames to generate. These will essentially be repeats of
4074 the first frame for the number of frames specified. The default is 1.
4075 amplitude_function : function, optional
4076 An optional function that should accept a frequency value and return
4077 an amplitude at that frequency. The default is constant scaling
4078 across all frequencies. Multiple amplitudes can be returned as long
4079 as they broadcast with the shape of the final TimeHistoryArray.
4080 force_integer_cycles : bool, optional
4081 If True, it will force an integer number of cycles, which will
4082 adjust the maximum frequency of the signal. This will ensure the
4083 signal is continuous if repeated. If False, the
4084 comment1 : np.ndarray, optional
4085 Comment used to describe the data in the data array. The default is ''.
4086 comment2 : np.ndarray, optional
4087 Comment used to describe the data in the data array. The default is ''.
4088 comment3 : np.ndarray, optional
4089 Comment used to describe the data in the data array. The default is ''.
4090 comment4 : np.ndarray, optional
4091 Comment used to describe the data in the data array. The default is ''.
4092 comment5 : np.ndarray, optional
4093 Comment used to describe the data in the data array. The default is ''.
4095 Returns
4096 -------
4097 TimeHistoryArray :
4098 A time history containing the specified chirp signal
4100 """
4101 if np.array(coordinates).dtype.type is np.str_:
4102 coordinates = coordinate_array(string_array=coordinates)
4103 # Get coordinate size
4104 if coordinates.ndim == 0 or coordinates.shape[-1] != 1:
4105 coordinates = coordinates[..., np.newaxis]
4106 # Create the chirp
4107 signal_length_in_time = dt*signal_length
4108 df = 1/signal_length_in_time
4109 if start_frequency is None:
4110 start_frequency = df
4111 if end_frequency is None:
4112 end_frequency = 1/dt/2-df
4113 ordinate = np.empty(coordinates.shape[:-1]+(signal_length,))
4114 ordinate[...] = chirp(start_frequency, end_frequency, signal_length_in_time,
4115 dt, force_integer_cycles)
4116 if amplitude_function is not None:
4117 if force_integer_cycles:
4118 n_cycles = np.ceil(end_frequency * signal_length_in_time)
4119 end_frequency = n_cycles / signal_length_in_time
4120 frequency_slope = (end_frequency - start_frequency) / signal_length
4121 frequency_over_time = start_frequency + frequency_slope*np.arange(signal_length)
4122 amplitude_over_time = np.array([amplitude_function(f) for f in frequency_over_time])
4123 ordinate *= amplitude_over_time
4124 # Create the measurement frames
4125 ordinate = np.tile(ordinate, frames)
4126 # Now create the object
4127 abscissa = dt * np.arange(signal_length*frames)
4128 time_history = data_array(FunctionTypes.TIME_RESPONSE,
4129 abscissa,
4130 ordinate,
4131 coordinates,
4132 comment1, comment2, comment3, comment4, comment5)
4133 return time_history
4135 @classmethod
4136 def pulse_signal(cls, dt, signal_length, coordinates,
4137 pulse_width=None, pulse_time=None, pulse_peak=1,
4138 sine_exponent=1, frames=1,
4139 comment1='', comment2='', comment3='',
4140 comment4='', comment5=''):
4141 """
4142 Creates a pulse using a cosine function raised to a specified exponent
4144 Parameters
4145 ----------
4146 dt : float
4147 Abscissa spacing in the final signal.
4148 signal_length : int
4149 Number of samples in the signal
4150 coordinates : CoordinateArray
4151 Coordinate array used to generate the signal. If the last dimension
4152 of coordinates is not shape 1, then a new axis will be added to make
4153 it shape 1. The shape of the resulting TimeHistoryArray will be
4154 determined by the shape of the input coordinates.
4155 pulse_width : float, optional
4156 With of the pulse in the same units as `dt`. The default is 5*dt.
4157 pulse_time : float, optional
4158 The time of the pulse's occurance in the same units as `dt`.
4159 The default is 5*dt.
4160 pulse_peak : float, optional
4161 The peak amplitude of the pulse. The default is 1.
4162 sine_exponent : float, optional
4163 The exponent that the cosine function is raised to. The default is 1.
4164 frames : int, optional
4165 Number of frames to generate. These will essentially be repeats of
4166 the first frame for the number of frames specified. The default is 1.
4167 comment1 : np.ndarray, optional
4168 Comment used to describe the data in the data array. The default is ''.
4169 comment2 : np.ndarray, optional
4170 Comment used to describe the data in the data array. The default is ''.
4171 comment3 : np.ndarray, optional
4172 Comment used to describe the data in the data array. The default is ''.
4173 comment4 : np.ndarray, optional
4174 Comment used to describe the data in the data array. The default is ''.
4175 comment5 : np.ndarray, optional
4176 Comment used to describe the data in the data array. The default is ''.
4178 Returns
4179 -------
4180 TimeHistoryArray :
4181 A time history containing the specified pulse signal
4183 """
4184 if np.array(coordinates).dtype.type is np.str_:
4185 coordinates = coordinate_array(string_array=coordinates)
4186 # Get coordinate size
4187 if coordinates.ndim == 0 or coordinates.shape[-1] != 1:
4188 coordinates = coordinates[..., np.newaxis]
4189 ordinate = np.empty(coordinates.shape[:-1]+(signal_length,))
4190 if pulse_time is None:
4191 pulse_time = dt*5
4192 if pulse_width is None:
4193 pulse_width = dt*5
4194 ordinate[...] = pulse(signal_length, pulse_time, pulse_width, pulse_peak,
4195 dt, sine_exponent)
4196 # Create the measurement frames
4197 ordinate = np.tile(ordinate, frames)
4198 # Now create the object
4199 abscissa = dt * np.arange(signal_length*frames)
4200 time_history = data_array(FunctionTypes.TIME_RESPONSE,
4201 abscissa,
4202 ordinate,
4203 coordinates,
4204 comment1, comment2, comment3, comment4, comment5)
4205 return time_history
4207 @classmethod
4208 def haversine_signal(cls, dt, signal_length, coordinates,
4209 pulse_width=None, pulse_time=None, pulse_peak=1,
4210 frames=1,
4211 comment1='', comment2='', comment3='',
4212 comment4='', comment5=''):
4213 """
4214 Creates a haversine pulse with the specified parameters
4216 Parameters
4217 ----------
4218 dt : float
4219 Abscissa spacing in the final signal.
4220 signal_length : int
4221 Number of samples in the signal
4222 coordinates : CoordinateArray, optional
4223 Coordinate array used to generate the signal. If the last dimension
4224 of coordinates is not shape 1, then a new axis will be added to make
4225 it shape 1. The shape of the resulting TimeHistoryArray will be
4226 determined by the shape of the input coordinates.
4227 pulse_width : float, optional
4228 With of the pulse in the same units as `dt`. The default is 5*dt.
4229 pulse_time : float, optional
4230 The time of the pulse's peak occurance in the same units as `dt`.
4231 The default is 5*dt.
4232 pulse_peak : float, optional
4233 The peak amplitude of the pulse. The default is 1.
4234 frames : int, optional
4235 Number of frames to generate. These will essentially be repeats of
4236 the first frame for the number of frames specified. The default is 1.
4237 comment1 : np.ndarray, optional
4238 Comment used to describe the data in the data array. The default is ''.
4239 comment2 : np.ndarray, optional
4240 Comment used to describe the data in the data array. The default is ''.
4241 comment3 : np.ndarray, optional
4242 Comment used to describe the data in the data array. The default is ''.
4243 comment4 : np.ndarray, optional
4244 Comment used to describe the data in the data array. The default is ''.
4245 comment5 : np.ndarray, optional
4246 Comment used to describe the data in the data array. The default is ''.
4248 Returns
4249 -------
4250 TimeHistoryArray :
4251 A time history containing the specified haversine pulse signal
4253 """
4254 if np.array(coordinates).dtype.type is np.str_:
4255 coordinates = coordinate_array(string_array=coordinates)
4256 # Get coordinate size
4257 if coordinates.ndim == 0 or coordinates.shape[-1] != 1:
4258 coordinates = coordinates[..., np.newaxis]
4259 abscissa_frame = np.arange(signal_length) * dt
4260 ordinate = np.zeros(coordinates.shape[:-1]+(signal_length,))
4261 if pulse_time is None:
4262 pulse_time = dt*5
4263 if pulse_width is None:
4264 pulse_width = dt*5
4265 pulse_time, pulse_width, pulse_peak = np.broadcast_arrays(pulse_time, pulse_width, pulse_peak)
4266 for time, width, peak in zip(pulse_time.flatten(), pulse_width.flatten(), pulse_peak.flatten()):
4267 period = width
4268 argument = 2 * np.pi / period * (abscissa_frame - time)
4269 ordinate += peak/2 * (1+np.cos(argument)) * (np.abs(argument) <= (np.pi))
4270 # Create the measurement frames
4271 ordinate = np.tile(ordinate, frames)
4272 abscissa = dt * np.arange(signal_length*frames)
4273 # Now create the object
4274 time_history = data_array(FunctionTypes.TIME_RESPONSE,
4275 abscissa,
4276 ordinate,
4277 coordinates,
4278 comment1, comment2, comment3, comment4, comment5)
4279 return time_history
4281 @classmethod
4282 def sine_sweep_signal(cls, dt, coordinates,
4283 frequency_breakpoints, sweep_types, sweep_rates,
4284 amplitudes = 1, phases = 1,
4285 comment1='', comment2='', comment3='',
4286 comment4='', comment5=''):
4287 frequency_breakpoints = np.array(frequency_breakpoints)
4288 broadcast_to_shape = coordinates.shape + (frequency_breakpoints.size,)
4289 full_amplitudes = np.broadcast_to(amplitudes,broadcast_to_shape)
4290 full_phases = np.broadcast_to(phases,broadcast_to_shape)
4291 full_sweep_types = np.broadcast_to(sweep_types, frequency_breakpoints.size-1)
4292 full_sweep_rates = np.broadcast_to(sweep_rates, frequency_breakpoints.size-1)
4293 output_signals = []
4294 for key,coordinate in coordinates.ndenumerate():
4295 output_signals.append(
4296 sine_sweep(
4297 dt, frequency_breakpoints, full_sweep_rates, full_sweep_types,
4298 full_amplitudes[key],full_phases[key]))
4299 output_signals = np.array(output_signals)
4300 abscissa = dt*np.arange(output_signals.shape[-1])
4301 # Create a time history array
4302 time_history = data_array(FunctionTypes.TIME_RESPONSE,
4303 abscissa,output_signals,coordinates.flatten()[:,np.newaxis],
4304 comment1, comment2, comment3, comment4, comment5)
4305 return time_history.reshape(coordinates.shape)
4308def time_history_array(abscissa,ordinate,coordinate,comment1='',comment2='',comment3='',comment4='',comment5=''):
4309 """
4310 Helper function to create a TimeHistoryArray object.
4312 All input arguments to this function are allowed to broadcast to create the
4313 final data in the TimeHistoryArray object.
4315 Parameters
4316 ----------
4317 abscissa : np.ndarray
4318 Numpy array specifying the abscissa of the function
4319 ordinate : np.ndarray
4320 Numpy array specifying the ordinate of the function
4321 coordinate : CoordinateArray
4322 Coordinate for each data in the data array
4323 comment1 : np.ndarray, optional
4324 Comment used to describe the data in the data array. The default is ''.
4325 comment2 : np.ndarray, optional
4326 Comment used to describe the data in the data array. The default is ''.
4327 comment3 : np.ndarray, optional
4328 Comment used to describe the data in the data array. The default is ''.
4329 comment4 : np.ndarray, optional
4330 Comment used to describe the data in the data array. The default is ''.
4331 comment5 : np.ndarray, optional
4332 Comment used to describe the data in the data array. The default is ''.
4334 Returns
4335 -------
4336 obj : TimeHistoryArray
4337 The constructed TimeHistoryArray object
4338 """
4339 return data_array(FunctionTypes.TIME_RESPONSE,abscissa,ordinate,coordinate,
4340 comment1,comment2,comment3,comment4,comment5)
4343class SpectrumArray(NDDataArray):
4344 """Data array used to store linear spectra (for example scaled FFT results)"""
4345 def __new__(subtype, shape, nelements, buffer=None, offset=0,
4346 strides=None, order=None):
4347 obj = super().__new__(subtype, shape, nelements, 1, 'complex128', buffer, offset, strides, order)
4348 return obj
4350 @property
4351 def function_type(self):
4352 """
4353 Returns the function type of the data array
4354 """
4355 return FunctionTypes.SPECTRUM
4357 def ifft(self, norm="backward", rtol=1, atol=1e-8, odd_num_samples = False,
4358 **scipy_irfft_kwargs):
4359 """
4360 Computes a time signal from the frequency spectrum
4362 Parameters
4363 ----------
4364 norm : str, optional
4365 The type of normalization applied to the fft computation.
4366 The default is "backward".
4367 rtol : float, optional
4368 Relative tolerance used in the abcsissa spacing check.
4369 The default is 1e-5.
4370 atol : float, optional
4371 Relative tolerance used in the abscissa spacing check.
4372 The default is 1e-8.
4373 odd_num_samples : bool, optional
4374 If True, then it is assumed that the output signal has an odd
4375 number of samples, meaning the signal will have a length of
4376 2*(m-1)+1 where m is the number of frequency lines. Otherwise, the
4377 default value of 2*(m-1) is used, assuming an even signal. This is
4378 ignored if num_samples is specified.
4379 scipy_irfft_kwargs :
4380 Additional keywords that will be passed to SciPy's irfft function.
4382 Raises
4383 ------
4384 ValueError
4385 Raised if the spectra passed to this function do not have
4386 equally spaced abscissa.
4387 NotImplementedError
4388 Raised if the user specifies scaling.
4390 Returns
4391 -------
4392 TimeHistoryArray
4393 The time history of the SpectrumArray.
4396 Notes
4397 -----
4398 Note that the ifft uses the rfft function from scipy to compute the
4399 inverse fast fourier transform. This function is not round-trip
4400 equivalent for odd functions, because by default it assumes an even
4401 signal length. For an odd signal length, the user must either specify
4402 odd_num_samples = True or set num_samples to the correct number of
4403 samples.
4404 """
4406 df = self.abscissa_spacing
4407 min_freq = self.abscissa.min()
4408 if min_freq % df > 0.01*df:
4409 raise ValueError('Frequency bins do not line up with zero. Cannot compute rfft bins.')
4410 first_frequency_bin = int(np.round(self.abscissa.min()/df))
4411 padding = np.zeros(self.ordinate.shape[:-1]+(first_frequency_bin,),self.ordinate.dtype)
4412 if padding.shape[-1] > 0:
4413 warnings.warn(
4414 'The FRFs are missing some low frequency data'
4415 + ' and it is assumed that this is due to some high pass cut-off.'
4416 + ' The data is being zero padded at low frequencies.')
4417 num_elements = first_frequency_bin+self.num_elements
4419 if odd_num_samples:
4420 num_samples = 2*(num_elements-1)+1
4421 else:
4422 num_samples = 2*(num_elements-1)
4424 # Organizing the FRFs for the ifft, this handles the zero padding if low frequency
4425 # data is missing
4426 ordinate = np.concatenate((padding,self.ordinate),axis=-1)
4427 irfft = scipyfft.irfft(ordinate, axis=-1, n=num_samples, norm=norm,
4428 **scipy_irfft_kwargs)
4430 # Building the time vectors
4431 dt = 1 / (self.abscissa.max()*num_samples/np.floor(num_samples/2))
4432 time_vector = dt * np.arange(num_samples)
4434 return data_array(FunctionTypes.TIME_RESPONSE, time_vector, irfft, self.coordinate,
4435 self.comment1, self.comment2, self.comment3, self.comment4, self.comment5)
4437 def interpolate_by_zero_pad(self, time_response_padded_length,
4438 return_time_response=False,
4439 odd_num_samples = False):
4440 """
4441 Interpolates a spectrum by zero padding or truncating its
4442 time response
4444 Parameters
4445 ----------
4446 time_response_padded_length : int
4447 Length of the final zero-padded time response
4448 return_time_response : bool, optional
4449 If True, the zero-padded impulse response function will be returned.
4450 If False, it will be transformed back to a transfer function prior
4451 to being returned.
4452 odd_num_samples : bool, optional
4453 If True, then it is assumed that the spectrum has been constructed
4454 from a signal with an odd number of samples. Note that this
4455 function uses the rfft function from scipy to compute the
4456 inverse fast fourier transform. The irfft function is not round-trip
4457 equivalent for odd functions, because by default it assumes an even
4458 signal length. For an odd signal length, the user must either specify
4459 odd_num_samples = True to make it round-trip equivalent.
4461 Returns
4462 -------
4463 SpectrumArray or TimeHistoryArray:
4464 Spectrum array with appropriately spaced abscissa
4466 Notes
4467 -----
4468 This function will automatically set the last frequency line of the
4469 SpectrumArray to zero because it won't be accurate anyway.
4470 If `time_response_padded_length` is less than the current function's
4471 `num_elements`, then it will be truncated instead of zero-padded.
4472 """
4473 time_response = self.ifft(odd_num_samples=odd_num_samples)
4474 if time_response_padded_length < time_response.num_elements:
4475 time_response = time_response.idx_by_el[:time_response_padded_length]
4476 else:
4477 time_response = time_response.zero_pad(
4478 time_response_padded_length - time_response.num_elements)
4479 if return_time_response:
4480 return time_response
4481 else:
4482 spectrum = time_response.fft()
4483 if time_response_padded_length % 2 == 0:
4484 spectrum.ordinate[..., -1] = 0
4485 return spectrum
4487 def apply_transformation(self, transformation, invert_transformation=False):
4488 """
4489 Applies response transformations spectra.
4491 Parameters
4492 ----------
4493 transformation : Matrix
4494 The transformation to apply to the spectra. It should be a
4495 SDynPy matrix object with the "transformed" coordinates on the
4496 rows and the "physical" coordinates on the columns. The matrix
4497 can be either 2D or 3D (for a frequency dependent transform).
4498 invert_reference_transformation : bool, optional
4499 Whether or not to invert the transformation when applying it to
4500 the spectra. The default is False, which is standard practice.
4501 The row/column ordering in the transformation should be flipped
4502 if this is set to true.
4504 Raises
4505 ------
4506 ValueError
4507 If the physical degrees of freedom in the transformation does not
4508 match the spectra.
4510 Returns
4511 -------
4512 transformed_spectra : SpectrumArray
4513 The spectra with the transformation applied.
4514 """
4515 if not self.validate_common_abscissa():
4516 raise ValueError('The abscissa must be consistent accross all functions in the NDDataArray')
4518 physical_coordinate = np.unique(self.response_coordinate)
4519 original_spectra_ordinate = np.moveaxis(self[physical_coordinate[...,np.newaxis]].ordinate, -1, 0)[..., np.newaxis]
4521 if invert_transformation:
4522 if not np.all(np.unique(transformation.row_coordinate) == physical_coordinate):
4523 raise ValueError('The physical coordinates in the transformation do no match the spectra')
4524 transformed_coordinate = np.unique(transformation.column_coordinate)
4525 transformation_matrix = np.linalg.pinv(transformation[physical_coordinate, transformed_coordinate])
4526 elif not invert_transformation:
4527 if not np.all(np.unique(transformation.column_coordinate) == physical_coordinate):
4528 raise ValueError('The physical coordinates in the transformation do no match the spectra')
4529 transformed_coordinate = np.unique(transformation.row_coordinate)
4530 transformation_matrix = transformation[transformed_coordinate, physical_coordinate]
4532 transformed_spectra_ordinate = (transformation_matrix @ original_spectra_ordinate)[...,0]
4534 return data_array(FunctionTypes.SPECTRUM, self.ravel().abscissa[0], np.moveaxis(transformed_spectra_ordinate, 0, -1),
4535 transformed_coordinate[...,np.newaxis])
4537 def plot(self, one_axis=True, subplots_kwargs={}, plot_kwargs={},
4538 abscissa_markers = None,
4539 abscissa_marker_labels = None, abscissa_marker_type = 'vline',
4540 abscissa_marker_plot_kwargs = {}):
4541 """
4542 Plot the spectra
4544 Parameters
4545 ----------
4546 one_axis : bool, optional
4547 Set to True to plot all data on one axis. Set to False to plot
4548 data on multiple subplots. one_axis can also be set to a
4549 matplotlib axis to plot data on an existing axis. The default is
4550 True.
4551 subplots_kwargs : dict, optional
4552 Keywords passed to the matplotlib subplots function to create the
4553 figure and axes. The default is {}.
4554 plot_kwargs : dict, optional
4555 Keywords passed to the matplotlib plot function. The default is {}.
4556 abscissa_markers : ndarray, optional
4557 Array containing abscissa values to mark on the plot to denote
4558 significant events.
4559 abscissa_marker_labels : str or ndarray
4560 Array of strings to label the abscissa_markers with, or
4561 alternatively a format string that accepts index and abscissa
4562 inputs (e.g. '{index:}: {abscissa:0.2f}'). By default no label
4563 will be applied.
4564 abscissa_marker_type : str
4565 The type of marker to use. This can either be the string 'vline'
4566 or a valid matplotlib symbol specifier (e.g. 'o', 'x', '.').
4567 abscissa_marker_plot_kwargs : dict
4568 Additional keyword arguments used when plotting the abscissa label
4569 markers.
4571 Returns
4572 -------
4573 axis : matplotlib axis or array of axes
4574 On which the data were plotted
4576 """
4577 if abscissa_markers is not None:
4578 if abscissa_marker_labels is None:
4579 abscissa_marker_labels = ['' for value in abscissa_markers]
4580 elif isinstance(abscissa_marker_labels,str):
4581 abscissa_marker_labels = [abscissa_marker_labels.format(
4582 index = i, abscissa = v) for i,v in enumerate(abscissa_markers)]
4584 if one_axis is True:
4585 figure, axis = plt.subplots(2, 1, **subplots_kwargs)
4586 lines = axis[0].plot(self.flatten().abscissa.T, np.angle(
4587 self.flatten().ordinate.T), **plot_kwargs)
4588 axis[0].set_ylabel('Phase')
4589 if abscissa_markers is not None:
4590 if abscissa_marker_type == 'vline':
4591 kwargs = {'color':'k'}
4592 kwargs.update(abscissa_marker_plot_kwargs)
4593 for value,label in zip(abscissa_markers,abscissa_marker_labels):
4594 axis[0].axvline(value, **kwargs)
4595 axis[0].annotate(label, xy = (value, axis[0].get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
4596 axis.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
4597 else:
4598 for line in lines:
4599 x = line.get_xdata()
4600 y = line.get_ydata()
4601 marker_y = np.interp(abscissa_markers, x, y)
4602 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
4603 kwargs.update(abscissa_marker_plot_kwargs)
4604 axis[0].plot(abscissa_markers,marker_y,**kwargs)
4605 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
4606 axis[0].annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
4607 lines = axis[1].plot(self.flatten().abscissa.T, np.abs(
4608 self.flatten().ordinate.T), **plot_kwargs)
4609 axis[1].set_yscale('log')
4610 axis[1].set_ylabel('Amplitude')
4611 if abscissa_markers is not None:
4612 if abscissa_marker_type == 'vline':
4613 kwargs = {'color':'k'}
4614 kwargs.update(abscissa_marker_plot_kwargs)
4615 for value,label in zip(abscissa_markers,abscissa_marker_labels):
4616 axis[1].axvline(value, **kwargs)
4617 axis[1].annotate(label, xy = (value, axis[1].get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
4618 axis[1].callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
4619 else:
4620 for line in lines:
4621 x = line.get_xdata()
4622 y = line.get_ydata()
4623 marker_y = np.interp(abscissa_markers, x, y)
4624 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
4625 kwargs.update(abscissa_marker_plot_kwargs)
4626 axis[1].plot(abscissa_markers,marker_y,**kwargs)
4627 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
4628 axis[1].annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
4629 elif one_axis is False:
4630 ncols = int(np.floor(np.sqrt(self.size)))
4631 nrows = int(np.ceil(self.size / ncols))
4632 figure, axis = plt.subplots(nrows, ncols, **subplots_kwargs)
4633 for i, (ax, (index, function)) in enumerate(zip(axis.flatten(), self.ndenumerate())):
4634 lines = ax.plot(function.abscissa.T, np.abs(function.ordinate.T), **plot_kwargs)
4635 ax.set_ylabel('/'.join([str(v) for i, v in function.coordinate.ndenumerate()]))
4636 ax.set_yscale('log')
4637 if abscissa_markers is not None:
4638 if abscissa_marker_type == 'vline':
4639 kwargs = {'color':'k'}
4640 kwargs.update(abscissa_marker_plot_kwargs)
4641 for value,label in zip(abscissa_markers,abscissa_marker_labels):
4642 ax.axvline(value, **kwargs)
4643 ax.annotate(label, xy = (value, ax.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
4644 ax.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
4645 else:
4646 for line in lines:
4647 x = line.get_xdata()
4648 y = line.get_ydata()
4649 marker_y = np.interp(abscissa_markers, x, y)
4650 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
4651 kwargs.update(abscissa_marker_plot_kwargs)
4652 ax.plot(abscissa_markers,marker_y,**kwargs)
4653 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
4654 ax.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
4655 for ax in axis.flatten()[i + 1:]:
4656 ax.remove()
4657 else:
4658 axis = one_axis
4659 lines = axis.plot(self.flatten().abscissa.T, np.abs(self.flatten().ordinate.T), **plot_kwargs)
4660 if abscissa_markers is not None:
4661 if abscissa_marker_type == 'vline':
4662 kwargs = {'color':'k'}
4663 kwargs.update(abscissa_marker_plot_kwargs)
4664 for value,label in zip(abscissa_markers,abscissa_marker_labels):
4665 axis.axvline(value, **kwargs)
4666 axis.annotate(label, xy = (value, axis.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
4667 axis.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
4668 else:
4669 for line in lines:
4670 x = line.get_xdata()
4671 y = line.get_ydata()
4672 marker_y = np.interp(abscissa_markers, x, y)
4673 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
4674 kwargs.update(abscissa_marker_plot_kwargs)
4675 axis.plot(abscissa_markers,marker_y,**kwargs)
4676 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
4677 axis.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
4678 return axis
4680 def plot_spectrogram(self, abscissa=None, axis=None,
4681 subplots_kwargs={},
4682 pcolormesh_kwargs={'shading': 'auto'},
4683 log_scale=True):
4684 """
4685 Plots a spectrogram
4687 Parameters
4688 ----------
4689 abscissa : np.ndarray
4690 Optional argument to specify as the abscissa values. If not
4691 specified, this will be the index of the flattened SpectrumArray.
4692 axis : matplotlib.axis, optional
4693 An optional argument that specifies the axis to plot the spectrogram
4694 on
4695 subplots_kwargs : dict, optional
4696 Optional keywords to specify to the subplots function that creates
4697 a new figure if `axis` is not specified.
4698 pcolormesh_kwargs : dict, optional
4699 Optional arguments to pass to the pcolormesh function
4700 log_scale : bool
4701 If True, the colormap will be applied logarithmically
4703 Returns
4704 -------
4705 ax : matplotlib.axis
4706 The axis on which the spectrogram was plotted
4707 """
4708 # Make sure the abscissa are common
4709 self.validate_common_abscissa()
4710 flat_self = self.flatten()
4711 data = abs(self.ordinate)
4712 if log_scale:
4713 data = np.log(data)
4714 y_coords = flat_self[0].abscissa
4715 if abscissa is None:
4716 abscissa = np.arange(self.size)
4717 if axis is None:
4718 fig, axis = plt.subplots(**subplots_kwargs)
4719 axis.pcolormesh(abscissa, y_coords, data.T, **pcolormesh_kwargs)
4720 return axis
4723def spectrum_array(abscissa,ordinate,coordinate,comment1='',comment2='',
4724 comment3='',comment4='',comment5=''):
4725 """
4726 Helper function to create a SpectrumArray object.
4728 All input arguments to this function are allowed to broadcast to create the
4729 final data in the SpectrumArray object.
4731 Parameters
4732 ----------
4733 abscissa : np.ndarray
4734 Numpy array specifying the abscissa of the function
4735 ordinate : np.ndarray
4736 Numpy array specifying the ordinate of the function
4737 coordinate : CoordinateArray
4738 Coordinate for each data in the data array
4739 comment1 : np.ndarray, optional
4740 Comment used to describe the data in the data array. The default is ''.
4741 comment2 : np.ndarray, optional
4742 Comment used to describe the data in the data array. The default is ''.
4743 comment3 : np.ndarray, optional
4744 Comment used to describe the data in the data array. The default is ''.
4745 comment4 : np.ndarray, optional
4746 Comment used to describe the data in the data array. The default is ''.
4747 comment5 : np.ndarray, optional
4748 Comment used to describe the data in the data array. The default is ''.
4750 Returns
4751 -------
4752 obj : SpectrumArray
4753 The constructed SpectrumArray object
4754 """
4755 return data_array(FunctionTypes.SPECTRUM,abscissa,ordinate,coordinate,
4756 comment1,comment2,comment3,comment4,comment5)
4759class PowerSpectralDensityArray(NDDataArray):
4760 """Data array used to store power spectral density arrays"""
4761 def __new__(subtype, shape, nelements, buffer=None, offset=0,
4762 strides=None, order=None):
4763 obj = super().__new__(subtype, shape, nelements, 2, 'complex128', buffer, offset, strides, order)
4764 return obj
4766 @property
4767 def function_type(self):
4768 """
4769 Returns the function type of the data array
4770 """
4771 return FunctionTypes.POWER_SPECTRAL_DENSITY
4773 @staticmethod
4774 def from_time_data(response_data: TimeHistoryArray,
4775 samples_per_average: int = None,
4776 overlap: float = 0.0,
4777 window=np.array((1.0,)),
4778 reference_data: TimeHistoryArray = None,
4779 only_asds = False):
4780 """
4781 Computes a PSD matrix from reference and response time histories
4783 Parameters
4784 ----------
4785 response_data : TimeHistoryArray
4786 Time data to be used as responses
4787 samples_per_average : int, optional
4788 Number of samples used to split up the signals into averages. The
4789 default is None, meaning the data is treated as a single measurement
4790 frame.
4791 overlap : float, optional
4792 The overlap as a fraction of the frame (e.g. 0.5 specifies 50% overlap).
4793 The default is 0.0, meaning no overlap is used.
4794 window : np.ndarray or str, optional
4795 A 1D ndarray with length samples_per_average that specifies the
4796 coefficients of the window. A Hann window is applied if not specified.
4797 If a string is specified, then the window will be obtained from scipy.
4798 reference_data : TimeHistoryArray
4799 Time data to be used as reference. If not specified, the response
4800 data will be used as references, resulting in a square CPSD matrix.
4802 Raises
4803 ------
4804 ValueError
4805 Raised if reference and response functions do not have consistent
4806 abscissa
4808 Returns
4809 -------
4810 PowerSpectralDensityArray
4811 A PSD array computed from the specified reference and
4812 response signals.
4814 """
4815 if reference_data is None:
4816 reference_data = response_data
4817 ref_ord = None
4818 ref_data = reference_data.flatten()
4819 elif only_asds:
4820 raise ValueError('`only_asds` cannot be true when reference data is '
4821 'specified')
4822 else:
4823 ref_data = reference_data.flatten()
4824 ref_ord = ref_data.ordinate
4825 res_data = response_data.flatten()
4826 res_ord = res_data.ordinate
4827 if ((not np.allclose(ref_data[0].abscissa,
4828 res_data[0].abscissa))
4829 or (not np.allclose(ref_data.abscissa_spacing,res_data.abscissa_spacing))):
4830 raise ValueError('Reference and Response Data should have identical abscissa!')
4831 dt = res_data.abscissa_spacing
4832 df, cpsd = sp_cpsd(res_ord, 1/dt, samples_per_average, overlap,
4833 window, reference_signals = ref_ord,only_asds = only_asds)
4834 freq = np.arange(cpsd.shape[0])*df
4835 # Now construct the transfer function array
4836 if only_asds:
4837 coordinate = np.concatenate((res_data.coordinate,
4838 ref_data.coordinate),axis=-1)
4839 else:
4840 coordinate = outer_product(res_data.coordinate.flatten(),
4841 ref_data.coordinate.flatten())
4842 return data_array(FunctionTypes.POWER_SPECTRAL_DENSITY,
4843 freq, np.moveaxis(cpsd, 0, -1), coordinate)
4845 def generate_time_history(self, time_length=None, output_oversample=1):
4846 """
4847 Generates a time history from a CPSD matrix
4849 Parameters
4850 ----------
4851 time_length : float, optional
4852 The length (in time, not samples) of the signal. If not specified,
4853 the signal length will be based on the frequency spacing and
4854 nyquist frequency of the CPSD matrix. If specified, a signal will
4855 be constructed using constant overlap and add techniques. A whole
4856 number of realizations will be constructed, so the output signal
4857 can be longer than the `time_length` specified.
4858 output_oversample : int, optional
4859 Oversample factor applied to the output signal. The default is 1.
4861 Raises
4862 ------
4863 ValueError
4864 If the entries in the CPSD matrix do not have consistent abscissa
4865 or equally spaced frequency bins.
4867 Returns
4868 -------
4869 time_history : TimeHistoryArray
4870 A time history satisfying the properties of the CPSD matrix.
4872 """
4873 matrix_format = self.reshape_to_matrix()
4874 coordinates = matrix_format[:, 0].response_coordinate
4875 cpsd_matrix = np.moveaxis(matrix_format.ordinate, -1, 0)
4876 if not self.validate_common_abscissa(rtol=1, atol=1e-8):
4877 raise ValueError('All functions in CPSD matrix must have the same abscissa')
4878 abs_diff = np.diff(matrix_format.abscissa)
4879 df = np.mean(abs_diff)
4880 if not np.allclose(abs_diff, df):
4881 raise ValueError('Abscissa must have constant frequency spacing. Max {:}, Min {:}'.format(
4882 abs_diff.max(), abs_diff.min()))
4883 sample_rate = 2 * matrix_format.abscissa.max()
4884 realization_length = 1 / df
4885 if time_length is None:
4886 realizations = 1
4887 else:
4888 realizations = int(np.ceil(time_length / realization_length * 2 - 1))
4889 # Do constant overlap and add
4890 final_signals = np.zeros((coordinates.size, (realizations+1) *
4891 (self.num_elements - 1)*output_oversample))
4892 window_function = sig.windows.hann(2 * (self.num_elements - 1)*output_oversample, sym=False)**0.5
4893 window_first_half = window_function.copy()
4894 window_first_half[window_first_half.size // 2:] = 1
4895 window_second_half = window_function.copy()
4896 window_second_half[:window_second_half.size // 2] = 1
4897 for i in range(realizations):
4898 indices = slice(i * (self.num_elements - 1)*output_oversample, (i + 2) * (self.num_elements - 1)*output_oversample)
4899 realization = cpsd_to_time_history(cpsd_matrix, sample_rate, df, output_oversample)
4900 if i > 0:
4901 realization *= window_first_half
4902 if i < realizations - 1:
4903 realization *= window_second_half
4904 final_signals[:, indices] += realization
4905 # Create time history array
4906 abscissa = np.arange(final_signals.shape[-1]) / sample_rate / output_oversample
4907 time_history = data_array(FunctionTypes.TIME_RESPONSE, abscissa,
4908 final_signals, coordinates[:, np.newaxis])
4909 return time_history
4911 def mimo_forward(self, transfer_function):
4912 """
4913 Compute the forward MIMO problem Gxx = Hxv@Gvv@Hxv*
4915 Parameters
4916 ----------
4917 transfer_function : TransferFunctionArray
4918 Transfer function used to transform the input matrix to the
4919 response matrix
4921 Raises
4922 ------
4923 ValueError
4924 If abscissa do not match between self and transfer function
4926 Returns
4927 -------
4928 PowerSpectralDensityArray
4929 Response CPSD matrix
4931 """
4932 # Check consistent abscissa
4933 abscissa = self.flatten()[0].abscissa
4934 if not np.allclose(abscissa, transfer_function.abscissa):
4935 raise ValueError('Transfer Function Abscissa do not match CPSD')
4936 if not np.allclose(abscissa, self.abscissa):
4937 raise ValueError('All CPSD abscissa must be identical')
4938 # First do bookkeeping, we want to get the coordinates of the response
4939 # of the FRF corresponding to the specification matrix
4940 transfer_function = transfer_function.reshape_to_matrix()
4941 response_dofs = transfer_function[:, 0].response_coordinate
4942 reference_dofs = transfer_function[0, :].reference_coordinate
4943 cpsd_dofs = outer_product(reference_dofs, reference_dofs)
4944 output_dofs = outer_product(response_dofs, response_dofs)
4945 frf_matrix = np.moveaxis(transfer_function.ordinate, -1, 0)
4946 cpsd_matrix = np.moveaxis(self[cpsd_dofs].ordinate, -1, 0)
4947 output_matrix = frf_matrix @ cpsd_matrix @ np.moveaxis(frf_matrix.conj(), -1, -2)
4948 return data_array(FunctionTypes.POWER_SPECTRAL_DENSITY,
4949 abscissa, np.moveaxis(output_matrix, 0, -1), output_dofs)
4951 def mimo_inverse(self, transfer_function,
4952 method='standard',
4953 response_weighting_matrix=None,
4954 reference_weighting_matrix=None,
4955 regularization_weighting_matrix=None,
4956 regularization_parameter=None,
4957 cond_num_threshold=None,
4958 num_retained_values=None):
4959 """
4960 Computes input estimation for MIMO random vibration problems
4962 Parameters
4963 ----------
4964 transfer_function : TransferFunctionArray
4965 System transfer functions used to estimate the input from the given
4966 response matrix
4967 method : str, optional
4968 The method to be used for the FRF matrix inversions. The available
4969 methods are:
4970 - standard - basic pseudo-inverse via numpy.linalg.pinv with the
4971 default rcond parameter, this is the default method
4972 - threshold - pseudo-inverse via numpy.linalg.pinv with a specified
4973 condition number threshold
4974 - tikhonov - pseudo-inverse using the Tikhonov regularization method
4975 - truncation - pseudo-inverse where a fixed number of singular values
4976 are retained for the inverse
4977 response_weighting_matrix : sdpy.Matrix, optional
4978 Diagonal matrix used to weight response degrees of freedom (to solve the
4979 problem as a weight least squares) by multiplying the rows of the FRF
4980 matrix by a scalar weights. This matrix can also be a 3D matrix such that
4981 the the weights are different for each frequency line. The matrix should
4982 be sized [number of lines, number of references, number of references],
4983 where the number of lines either be one (the same weights at all frequencies)
4984 or the length of the abscissa (for the case where a 3D matrix is supplied).
4985 reference_weighting_matrix : sdpy.Matrix, optional
4986 Diagonal matrix used to weight reference degrees of freedom (generally for
4987 normalization) by multiplying the columns of the FRF matrix by a scalar weights.
4988 This matrix can also be a 3D matrix such that the the weights are different
4989 for each frequency line. The matrix should be sized
4990 [number of lines, number of references, number of references], where the number
4991 of lines either be one (the same weights at all frequencies) or the length
4992 of the abscissa (for the case where a 3D matrix is supplied).
4993 regularization_weighting_matrix : sdpy.Matrix, optional
4994 Matrix used to weight input degrees of freedom via Tikhonov regularization.
4995 This matrix can also be a 3D matrix such that the the weights are different
4996 for each frequency line. The matrix should be sized
4997 [number of lines, number of references, number of references], where the number
4998 of lines either be one (the same weights at all frequencies) or the length
4999 of the abscissa (for the case where a 3D matrix is supplied).
5000 regularization_parameter : float or np.ndarray, optional
5001 Scaling parameter used on the regularization weighting matrix when the tikhonov
5002 method is chosen. A vector of regularization parameters can be provided so the
5003 regularization is different at each frequency line. The vector must match the
5004 length of the abscissa in this case (either be size [num_lines,] or [num_lines, 1]).
5005 cond_num_threshold : float or np.ndarray, optional
5006 Condition number used for SVD truncation when the threshold method is chosen.
5007 A vector of condition numbers can be provided so it varies as a function of
5008 frequency. The vector must match the length of the abscissa in this case.
5009 num_retained_values : float or np.ndarray, optional
5010 Number of singular values to retain in the pseudo-inverse when the truncation
5011 method is chosen. A vector of can be provided so the number of retained values
5012 can change as a function of frequency. The vector must match the length of the
5013 abscissa in this case.
5015 Raises
5016 ------
5017 ValueError
5018 If Abscissa are not consistent
5020 Returns
5021 -------
5022 PowerSpectralDensityArray
5023 Input CPSD matrix
5025 Notes
5026 -----
5027 This function solves the MIMO problem Gxx = Hxv@Gvv@Hxv^* using the pseudoinverse.
5028 Gvv = Hxv^+@Gxx@Hxv^+^*, where Gvv is the source.
5030 References
5031 ----------
5032 .. [1] Wikipedia, "Moore-Penrose inverse".
5033 https://en.wikipedia.org/wiki/Moore%E2%80%93Penrose_inverse
5034 .. [2] A.N. Tithe, D.J. Thompson, The quantification of structure-borne transmission pathsby inverse methods. Part 2: Use of regularization techniques,
5035 Journal of Sound and Vibration, Volume 264, Issue 2, 2003, Pages 433-451, ISSN 0022-460X,
5036 https://doi.org/10.1016/S0022-460X(02)01203-8.
5037 .. [3] Wikipedia, "Ridge regression".
5038 https://en.wikipedia.org/wiki/Ridge_regression
5039 """
5040 # Check consistent abscissa
5041 abscissa = self.flatten()[0].abscissa
5042 if not np.allclose(abscissa, transfer_function.abscissa):
5043 raise ValueError('Transfer Function Abscissa do not match CPSD')
5044 if not np.allclose(abscissa, self.abscissa):
5045 raise ValueError('All CPSD abscissa must be identical')
5046 # First do bookkeeping, we want to get the coordinates of the response
5047 # of the FRF corresponding to the specification matrix
5048 transfer_function = transfer_function.reshape_to_matrix()
5049 response_dofs = transfer_function[:, 0].response_coordinate
5050 reference_dofs = transfer_function[0, :].reference_coordinate
5051 cpsd_dofs = outer_product(response_dofs, response_dofs)
5052 output_dofs = outer_product(reference_dofs, reference_dofs)
5053 frf_matrix = np.moveaxis(transfer_function.ordinate, -1, 0)
5054 cpsd_matrix = np.moveaxis(self[cpsd_dofs].ordinate.copy(), -1, 0)
5055 # Perform the generalized inversion
5056 if response_weighting_matrix is not None:
5057 if isinstance(response_weighting_matrix, Matrix):
5058 response_weighting_matrix = response_weighting_matrix[response_dofs, response_dofs]
5059 cpsd_matrix = response_weighting_matrix @ cpsd_matrix @ np.moveaxis(response_weighting_matrix.conj(), -1, -2)
5060 if reference_weighting_matrix is not None:
5061 if isinstance(reference_weighting_matrix, Matrix):
5062 reference_weighting_matrix = reference_weighting_matrix[reference_dofs, reference_dofs]
5063 if isinstance(regularization_weighting_matrix, Matrix):
5064 regularization_weighting_matrix = regularization_weighting_matrix[reference_dofs, reference_dofs]
5065 frf_pinv = frf_inverse(frf_matrix,
5066 method=method,
5067 response_weighting_matrix=response_weighting_matrix,
5068 reference_weighting_matrix=reference_weighting_matrix,
5069 regularization_weighting_matrix=regularization_weighting_matrix,
5070 regularization_parameter=regularization_parameter,
5071 cond_num_threshold=cond_num_threshold,
5072 num_retained_values=num_retained_values)
5073 method_statement_start = 'The inputs are being computed using the '
5074 method_statement_end = ' method'
5075 print(method_statement_start+method+method_statement_end)
5076 output_matrix = frf_pinv @ cpsd_matrix @ np.moveaxis(frf_pinv.conj(), -1, -2)
5077 if reference_weighting_matrix is not None:
5078 output_matrix = reference_weighting_matrix@output_matrix@reference_weighting_matrix
5079 return data_array(FunctionTypes.POWER_SPECTRAL_DENSITY,
5080 abscissa, np.moveaxis(output_matrix, 0, -1), output_dofs)
5082 def error_summary(self, figure_kwargs={}, linewidth=1, plot_kwargs={}, **cpsd_matrices):
5083 """
5084 Plots an error summary compared to the current array
5086 Parameters
5087 ----------
5088 figure_kwargs : dict, optional
5089 Arguments to use when creating the figure. The default is {}.
5090 linewidth : float, optional
5091 Widths of the lines on the plot. The default is 1.
5092 plot_kwargs : dict, optional
5093 Arguments to use when plotting the lines. The default is {}.
5094 **cpsd_matrices : PowerSpectralDensityArray
5095 Data to compare against the current CPSD matrix. The keys will be
5096 used as labels with _ replaced with a space.
5098 Raises
5099 ------
5100 ValueError
5101 If CPSD abscissa do not match
5103 Returns
5104 -------
5105 Error Metrics
5106 A tuple of dictionaries of error metrics
5108 """
5109 def rms(x, axis=None):
5110 return np.sqrt(np.mean(x**2, axis=axis))
5112 def dB_pow(x): return 10 * np.log10(x)
5113 frequencies = self.flatten()[0].abscissa
5114 for legend, cpsd in cpsd_matrices.items():
5115 if not np.allclose(frequencies, cpsd.abscissa):
5116 raise ValueError('Compared CPSD abscissa do not match')
5117 if not np.allclose(frequencies, self.abscissa):
5118 raise ValueError('All CPSD abscissa must be identical')
5119 # Get ASDs
5120 responses = np.unique(abs(self.coordinate))
5121 response_dofs = np.tile(responses[:, np.newaxis], 2)
5122 channel_names = responses.string_array()
5123 spec_asd = np.real(self[response_dofs].ordinate)
5124 data_asd = {legend: np.real(data[response_dofs].ordinate) for legend, data in cpsd_matrices.items()}
5125 num_channels = spec_asd.shape[0]
5126 ncols = int(np.floor(np.sqrt(num_channels)))
5127 nrows = int(np.ceil(num_channels / ncols))
5128 if len(cpsd_matrices) > 1:
5129 total_rows = nrows + 2
5130 elif len(cpsd_matrices) == 1:
5131 total_rows = nrows + 1
5132 else:
5133 total_rows = nrows
5134 fig = plt.figure(**figure_kwargs)
5135 grid_spec = plt.GridSpec(total_rows, ncols, figure=fig)
5136 for i in range(num_channels):
5137 this_row = i // ncols
5138 this_col = i % ncols
5139 if i == 0:
5140 ax = fig.add_subplot(grid_spec[this_row, this_col])
5141 original_ax = ax
5142 else:
5143 ax = fig.add_subplot(grid_spec[this_row, this_col], sharex=original_ax,
5144 sharey=original_ax)
5145 ax.plot(frequencies, spec_asd[i], linewidth=linewidth * 2, color='k', **plot_kwargs)
5146 for legend, data in data_asd.items():
5147 ax.plot(frequencies, data[i], linewidth=linewidth)
5148 ax.set_ylabel(channel_names[i])
5149 if i == 0:
5150 ax.set_yscale('log')
5151 if this_row == nrows - 1:
5152 ax.set_xlabel('Frequency (Hz)')
5153 else:
5154 plt.setp(ax.get_xticklabels(), visible=False)
5155 if this_col != 0:
5156 plt.setp(ax.get_yticklabels(), visible=False)
5157 return_data = None
5158 if len(cpsd_matrices) > 0:
5159 spec_sum_asd = np.sum(spec_asd, axis=0)
5160 data_sum_asd = {legend: np.sum(data, axis=0) for legend, data in data_asd.items()}
5161 db_error = {legend: rms(dB_pow(data) - dB_pow(spec_asd), axis=0)
5162 for legend, data in data_asd.items()}
5163 plot_width = ncols // 2
5164 ax = fig.add_subplot(grid_spec[nrows, 0:plot_width])
5165 ax.plot(frequencies, spec_sum_asd, linewidth=2 * linewidth, color='k')
5166 for legend, data in data_sum_asd.items():
5167 ax.plot(frequencies, data, linewidth=linewidth)
5168 ax.set_yscale('log')
5169 ax.set_ylabel('Sum ASDs')
5170 ax = fig.add_subplot(grid_spec[nrows, -plot_width:])
5171 for legend, data in db_error.items():
5172 ax.plot(frequencies, data, linewidth=linewidth)
5173 ax.set_ylabel('dB Error')
5174 if len(cpsd_matrices) > 1:
5175 prop_cycle = plt.rcParams['axes.prop_cycle']
5176 colors = prop_cycle.by_key()['color'] * 100
5177 db_error_sum_asd = {legend: rms(dB_pow(sum_asd) - dB_pow(spec_sum_asd))
5178 for legend, sum_asd in data_sum_asd.items()}
5179 db_error_rms = {legend: rms(data) for legend, data in db_error.items()}
5180 return_data = (db_error_sum_asd, db_error_rms)
5181 ax = fig.add_subplot(grid_spec[nrows + 1, 0:plot_width])
5182 for i, (legend, data) in enumerate(db_error_sum_asd.items()):
5183 ax.bar(i, data, color=colors[i])
5184 ax.text(i, 0, '{:.2f}'.format(data),
5185 horizontalalignment='center', verticalalignment='bottom')
5186 ax.set_xticks(np.arange(i + 1))
5187 ax.set_xticklabels([legend.replace('_', ' ')
5188 for legend in db_error_sum_asd], rotation=20, horizontalalignment='right')
5189 ax.set_ylabel('Sum RMS dB Error')
5190 ax = fig.add_subplot(grid_spec[nrows + 1, -plot_width:])
5191 for i, (legend, data) in enumerate(db_error_rms.items()):
5192 ax.bar(i, data, color=colors[i])
5193 ax.text(i, 0, '{:.2f}'.format(data),
5194 horizontalalignment='center', verticalalignment='bottom')
5195 ax.set_xticks(np.arange(i + 1))
5196 ax.set_xticklabels([legend.replace('_', ' ')
5197 for legend in db_error_rms], rotation=20, horizontalalignment='right')
5198 ax.set_ylabel('RMS dB Error')
5199 fig.tight_layout()
5200 return return_data
5202 def svd(self, full_matrices=True, compute_uv=True, as_matrix=True):
5203 """
5204 Compute the SVD of the provided CPSD matrix
5206 Parameters
5207 ----------
5208 full_matrices : bool, optional
5209 This is an optional input for np.linalg.svd
5210 compute_uv : bool, optional
5211 This is an optional input for np.linalg.svd
5212 as_matrix : bool, optional
5213 If True, matrices are returned as a SDynPy Matrix class with named
5214 rows and columns. Otherwise, a simple numpy array is returned
5216 Returns
5217 -------
5218 u : ndarray
5219 Left hand singular vectors, sized [..., num_responses, num_responses].
5220 Only returned when compute_uv is True.
5221 s : ndarray
5222 Singular values, sized [..., num_references]
5223 vh : ndarray
5224 Right hand singular vectors, sized [..., num_references, num_references].
5225 Only returned when compute_uv is True.
5226 """
5227 cpsd = self.reshape_to_matrix()
5228 cpsdOrd = np.moveaxis(cpsd.ordinate, -1, 0)
5229 if compute_uv:
5230 u, s, vh = np.linalg.svd(cpsdOrd, full_matrices, compute_uv)
5231 if as_matrix:
5232 u = matrix(u, cpsd[:, 0].response_coordinate,
5233 coordinate_array(np.arange(u.shape[-1])+1, 0))
5234 s = matrix(s[:, np.newaxis]*np.eye(s.shape[-1]), coordinate_array(np.arange(s.shape[-1])+1, 0),
5235 coordinate_array(np.arange(s.shape[-1])+1, 0))
5236 vh = matrix(vh, coordinate_array(np.arange(vh.shape[-2])+1, 0),
5237 cpsd[0, :].reference_coordinate,
5238 )
5239 return u, s, vh
5240 else:
5241 s = np.linalg.svd(cpsdOrd, full_matrices, compute_uv)
5242 if as_matrix:
5243 s = matrix(s[:, np.newaxis]*np.eye(s.shape[-1]), coordinate_array(np.arange(s.shape[-1])+1, 0),
5244 coordinate_array(np.arange(s.shape[-1])+1, 0))
5245 return s
5247 def get_asd(self):
5248 """
5249 Get functions where the response coordinate is equal to the reference coordinate
5251 Returns
5252 -------
5253 PowerSpectralDensityArray
5254 PowerSpectralDensityArrays where the response is equal to the reference
5256 """
5257 if self.ndim > 0:
5258 indices = np.where(abs(self.coordinate[..., 0]) == abs(self.coordinate[..., 1]))
5259 return self[indices]
5260 elif self.ndim == 0:
5261 if abs(self.coordinate[..., 0]) == abs(self.coordinate[..., 1]):
5262 return self
5263 else:
5264 return PowerSpectralDensityArray(shape=(),nelements=self.num_elements)
5266 def rms(self,oct_order=None):
5267 """
5268 Compute RMSs of the PSDs using the diagonals
5270 Parameters
5271 ----------
5272 oct_order : int, optional
5273 octave type, 1/octave order. 3 represents 1/3 octave bands. default is None
5275 Returns
5276 -------
5277 ndarray
5278 RMS values for the ASDS
5280 """
5281 asd = self.get_asd()
5282 if oct_order == None:
5283 abscissa_spacing = self.abscissa_spacing
5284 rms_levels = np.sqrt(np.sum(asd.ordinate.real, axis=-1)*abscissa_spacing)
5285 else:
5286 # Get Spectra Limits
5287 xlim = np.array([np.min(asd.abscissa),np.max(asd.abscissa)])
5288 # Get Frequency Spacing
5289 nominal_band_centers,band_lb,band_ub,band_centers = nth_octave_freqs(freq=xlim,oct_order=oct_order)
5290 # Compute RMS Levels
5291 df = band_ub-band_lb
5292 rms_levels = np.sqrt(np.sum(df*asd.ordinate.real,axis=-1))
5293 return rms_levels
5295 def plot_asds(self, figure_kwargs={}, linewidth=1):
5296 asds = self.get_asd()
5297 try:
5298 rms = asds.rms()
5299 except ValueError:
5300 rms = None
5301 ax = asds.plot(one_axis=False, subplots_kwargs=figure_kwargs, plot_kwargs={'linewidth': linewidth})
5302 for i, (a,asd) in enumerate(zip(ax.flatten(),asds)):
5303 if rms is not None:
5304 a.set_ylabel(a.get_ylabel()+'\nRMS: {:0.4f}'.format(rms[i]))
5305 a.set_yscale('log')
5306 return ax
5308 @staticmethod
5309 def compare_asds(figure_kwargs={}, linewidth=1, **cpsd_matrices):
5310 """
5311 Plot the diagonals of the CPSD matrix, as well as the level
5313 Parameters
5314 ----------
5315 figure_kwargs : dict, optional
5316 Optional arguments to use when creating the figure. The default is {}.
5317 linewidth : float, optional
5318 Width of plotted lines. The default is 1.
5319 **cpsd_matrices : PowerSpectralDensityArray
5320 PSDs to plot. Only gets plotted if response and reference are
5321 identical. The key will be used as the label with _ replaced by a
5322 space.
5324 Raises
5325 ------
5326 ValueError
5327 If degrees of freedom are not consistent between PSDs
5329 Returns
5330 -------
5331 None.
5333 """
5334 asds = {legend: cpsd.get_asd() for legend, cpsd in cpsd_matrices.items()}
5335 for i, (legend, asd) in enumerate(asds.items()):
5336 this_dofs = np.unique(abs(asd.coordinate))
5337 this_abscissa = asd.abscissa
5338 if i == 0:
5339 dofs = this_dofs
5340 if not np.all(this_dofs == dofs):
5341 raise ValueError('CPSDs must have identical dofs')
5342 # Sort the dofs correctly
5343 asds = {legend: asd[np.tile(dofs[:, np.newaxis], 2)] for legend, asd in asds.items()}
5344 num_channels = len(dofs)
5345 ncols = int(np.floor(np.sqrt(num_channels)))
5346 nrows = int(np.ceil(num_channels / ncols))
5347 total_rows = nrows + 1
5348 fig = plt.figure(**figure_kwargs)
5349 grid_spec = plt.GridSpec(total_rows, ncols, figure=fig)
5350 for i in range(num_channels):
5351 this_row = i // ncols
5352 this_col = i % ncols
5353 if i == 0:
5354 ax = fig.add_subplot(grid_spec[this_row, this_col])
5355 original_ax = ax
5356 else:
5357 ax = fig.add_subplot(grid_spec[this_row, this_col], sharex=original_ax,
5358 sharey=original_ax)
5359 for legend, data in asds.items():
5360 ax.plot(data[i].abscissa, np.real(data[i].ordinate), linewidth=linewidth)
5361 ax.set_ylabel(str(dofs[i]))
5362 if i == 0:
5363 ax.set_yscale('log')
5364 if this_row == nrows - 1:
5365 ax.set_xlabel('Frequency (Hz)')
5366 else:
5367 plt.setp(ax.get_xticklabels(), visible=False)
5368 if this_col != 0:
5369 plt.setp(ax.get_yticklabels(), visible=False)
5370 prop_cycle = plt.rcParams['axes.prop_cycle']
5371 colors = prop_cycle.by_key()['color'] * 10
5372 ax = fig.add_subplot(grid_spec[total_rows-1, 0:ncols])
5373 legend_handles = []
5374 legend_strings = []
5375 for i, (legend, asd) in enumerate(asds.items()):
5376 for j, fn in enumerate(asd):
5377 rms = np.sqrt(np.sum(fn.ordinate.real)*np.mean(np.diff(fn.abscissa)))
5378 x = i+(len(asds)+1)*j
5379 a = ax.bar(x, rms, color=colors[i])
5380 if j == 0:
5381 legend_handles.append(a)
5382 legend_strings.append(legend.replace('_', ' '))
5383 ax.text(x, 0, ' {:.2f}'.format(rms),
5384 horizontalalignment='center', verticalalignment='bottom', rotation=90)
5385 # Set XTicks
5386 xticks = np.mean(np.arange(len(asds))) + np.arange(len(dofs))*(len(asds)+1)
5387 xticklabels = [str(dof) for dof in dofs]
5388 ax.set_xticks(xticks)
5389 ax.set_xticklabels(xticklabels)
5390 ax.set_ylabel('RMS Levels')
5391 fig.tight_layout()
5392 legend = ax.legend(legend_handles, legend_strings, bbox_to_anchor=(1, 1))
5393 fig.canvas.draw()
5394 legend_width = legend.get_window_extent().width
5395 figure_width = fig.bbox.width
5396 figure_fraction = legend_width/figure_width
5397 ax_position = ax.get_position()
5398 ax_position.x1 -= figure_fraction
5399 ax.set_position(ax_position)
5401 def plot_singular_values(self, rcond=None, min_freqency=None, max_frequency=None):
5402 """
5403 Plot the singular values of an FRF matrix with a visualization of the rcond tolerance
5405 Parameters
5406 ----------
5407 rcond : value of float, optional
5408 Cutoff for small singular values. Implemented such that the cutoff is rcond*
5409 largest_singular_value (the same as np.linalg.pinv). This is to visualize the
5410 effect of rcond and is used for display purposes only.
5411 min_frequency : float, optional
5412 Minimum frequency to plot
5413 max_frequency : float, optional
5414 Maximum frequency to plot
5416 """
5417 freq = self.flatten().abscissa[0, :]
5418 s_cpsd = self.svd(compute_uv=False, as_matrix=False)
5419 plt.figure()
5420 plt.semilogy(freq, s_cpsd)
5421 if rcond is not None:
5422 cutoff = s_cpsd[:, 0] * rcond
5423 plt.semilogy(freq, cutoff, color='k', linestyle='dashed', linewidth=3)
5424 plt.grid()
5425 plt.xlabel('Frequency (Hz)')
5426 plt.ylabel('Singular Values')
5427 plt.title('Singular Values of CPSD Matrix')
5428 if min_freqency is not None:
5429 plt.xlim(left=min_freqency)
5430 if max_frequency is not None:
5431 plt.xlim(right=max_frequency)
5433 def coherence(self):
5434 """
5435 Computes the coherence of a PSD matrix
5437 Raises
5438 ------
5439 ValueError
5440 If abscissa are not consistent.
5442 Returns
5443 -------
5444 CoherenceArray
5445 CoherenceArray containing the values of coherence for each function.
5447 """
5448 reshaped_array = self.reshape_to_matrix()
5449 abscissa = reshaped_array[0, 0].abscissa
5450 if not np.allclose(reshaped_array.abscissa, abscissa):
5451 raise ValueError('All functions must have identical abscissa')
5452 cpsd_matrix = np.moveaxis(reshaped_array.ordinate, -1, 0)
5453 coherence_matrix = np.moveaxis(sp_coherence(cpsd_matrix), 0, -1)
5454 coherence_array = data_array(
5455 FunctionTypes.COHERENCE, abscissa=reshaped_array.abscissa,
5456 ordinate=coherence_matrix, coordinate=reshaped_array.coordinate)
5457 return coherence_array[self.coordinate]
5459 def angle(self):
5460 """
5461 Computes the angle of a PSD matrix
5463 Returns
5464 -------
5465 NDDataArray
5466 Data array consisting of the angle of each function at each
5467 frequency line
5469 """
5470 return data_array(FunctionTypes.GENERAL, self.abscissa, np.angle(self.ordinate),
5471 self.coordinate)
5473 def set_coherence_phase(self, coherence_array, angle_array):
5474 """
5475 Sets the coherence and phase of a PSD matrix while maintaining the ASDs
5477 Parameters
5478 ----------
5479 coherence_array : CoherenceArray
5480 Coherence to which the PSD will be set
5481 angle_array : NDDataArray
5482 Phase to which the PSD will be set
5484 Returns
5485 -------
5486 output : PowerSpectralDensityArray
5487 PSD with coherence and phase matching that of the input argument
5489 """
5490 asds = self.get_asd()
5491 dofs = outer_product(asds.response_coordinate, asds.reference_coordinate)
5492 reshaped_coherence = coherence_array[dofs]
5493 reshaped_angle = angle_array[dofs]
5494 asd_matrix = np.moveaxis(asds.ordinate, -1, 0)
5495 coherence_matrix = np.moveaxis(reshaped_coherence.ordinate, -1, 0)
5496 phase_matrix = np.moveaxis(reshaped_angle.ordinate, -1, 0)
5497 cpsd_matrix = cpsd_from_coh_phs(asd_matrix, coherence_matrix, phase_matrix)
5498 output = data_array(FunctionTypes.POWER_SPECTRAL_DENSITY,
5499 asds[0].abscissa, np.moveaxis(cpsd_matrix, 0, -1),
5500 dofs)[dofs]
5501 return output
5503 def get_cpsd_from_asds(self):
5504 """
5505 Transforms ASDs to a full CPSD matrix with zeros on the off-diagonals
5507 Returns
5508 -------
5509 output : PowerSpectralDensityArray
5510 CPSD matrix with the inputs on the diagonals and the off-diagonals
5511 as zeros.
5513 """
5514 asds = self.get_asd()
5515 dofs = outer_product(asds.response_coordinate, asds.reference_coordinate)
5516 asd_matrix = np.moveaxis(asds.ordinate, -1, 0)
5517 coherence_matrix = np.tile(np.eye(dofs.shape[0]),(asd_matrix.shape[0],1,1))
5518 phase_matrix = 0
5519 cpsd_matrix = cpsd_from_coh_phs(asd_matrix, coherence_matrix, phase_matrix)
5520 output = data_array(FunctionTypes.POWER_SPECTRAL_DENSITY,
5521 asds[0].abscissa, np.moveaxis(cpsd_matrix, 0, -1),
5522 dofs)[dofs]
5523 return output
5525 @classmethod
5526 def eye(cls, frequencies, coordinates, rms=None, full_matrix=False,
5527 breakpoint_frequencies=None, breakpoint_levels=None,
5528 breakpoint_interpolation='lin',
5529 min_frequency=0.0, max_frequency=None):
5530 """
5531 Computes a diagonal CPSD matrix
5533 Parameters
5534 ----------
5535 frequencies : ndarray
5536 Frequencies at which the CPSD should be constructed
5537 coordinates : CoordinateArray
5538 CoordinateArray to use to set the CPSD values
5539 rms : ndarray, optional
5540 Value to scale the RMS of each CPSD to
5541 full_matrix : bool, optional
5542 If True, a full, square CPSD matrix will be computed. If False, only
5543 the ASDs will be computed. The default is False.
5544 breakpoint_frequencies : iterable, optional
5545 A list of frequencies that breakpoints are defined at.
5546 breakpoint_levels : iterable, optional
5547 A list of levels that breakpoints are defined at
5548 breakpoint_interpolation : str, optional
5549 'lin' or 'log' to specify the type of interpolation. The default is
5550 'lin'.
5551 min_frequency : float, optional
5552 Low frequency cutoff for the CPSD. Frequency lines below this value
5553 will be set to zero.
5554 max_frequency : float, optional
5555 High frequency cutoff for the CPSD. Frequency lines above this value
5556 will be set to zero.
5558 Raises
5559 ------
5560 ValueError
5561 If invalid interpolation is specified, or if RMS is specified with
5562 inconsistent frequency spacing.
5564 Returns
5565 -------
5566 PowerSpectralDensityArray
5567 A set of PSDs.
5569 """
5570 if breakpoint_frequencies is None or breakpoint_levels is None:
5571 cpsd = np.ones(frequencies.shape)
5572 else:
5573 if breakpoint_interpolation in ['log', 'logarithmic']:
5574 cpsd = np.interp(np.log(frequencies), np.log(breakpoint_frequencies),
5575 breakpoint_levels, left=0, right=0)
5576 elif breakpoint_interpolation in ['lin', 'linear']:
5577 cpsd = np.interp(frequencies, breakpoint_frequencies, breakpoint_levels)
5578 else:
5579 raise ValueError('Invalid Interpolation, should be "lin" or "log"')
5581 # Truncate to the minimum frequency
5582 cpsd[frequencies < min_frequency] = 0
5583 if max_frequency is not None:
5584 cpsd[frequencies > max_frequency] = 0
5586 if rms is not None:
5587 frequency_spacing = np.mean(np.diff(frequencies))
5588 if not np.allclose(frequency_spacing, np.diff(frequencies)):
5589 raise ValueError('In order to specify RMS, the spacing of frequencies must be constant')
5590 cpsd_rms = np.sqrt(np.sum(cpsd) * frequency_spacing)
5591 cpsd *= (rms / cpsd_rms)**2
5593 num_channels = coordinates.size
5594 if full_matrix:
5595 full_cpsd = np.zeros((num_channels, num_channels, frequencies.size))
5596 full_cpsd[np.arange(num_channels), np.arange(num_channels), :] = cpsd
5597 cpsd_coordinates = outer_product(coordinates, coordinates)
5598 else:
5599 full_cpsd = np.tile(cpsd, (num_channels, 1))
5600 cpsd_coordinates = np.tile(coordinates[:, np.newaxis], (1, 2))
5602 return data_array(FunctionTypes.POWER_SPECTRAL_DENSITY, frequencies,
5603 full_cpsd, cpsd_coordinates)
5605 def apply_transformation(self, transformation, invert_transformation=False):
5606 """
5607 Applies a transformation to a cross power spectral density matrix.
5609 Parameters
5610 ----------
5611 transformation : Matrix
5612 The transformation to apply to the spectra. It should be a
5613 SDynPy matrix object with the "transformed" coordinates on the
5614 rows and the "physical" coordinates on the columns. The matrix
5615 can be either 2D or 3D (for a frequency dependent transform).
5616 invert_reference_transformation : bool, optional
5617 Whether or not to invert the transformation when applying it to
5618 the spectra. The default is False, which is standard practice.
5619 The row/column ordering in the transformation should be flipped
5620 if this is set to true.
5622 Raises
5623 ------
5624 ValueError
5625 If the cross power spectral density matrix is not square.
5626 ValueError
5627 If the physical degrees of freedom in the transformation does not
5628 match the spectra.
5630 Returns
5631 -------
5632 transformed_spectra : PowerSpectralDensityArray
5633 The cross power spectral density with the transformation applied.
5634 """
5635 if not self.validate_common_abscissa():
5636 raise ValueError('The abscissa must be consistent accross all functions in the NDDataArray')
5638 if self.ordinate.size != (np.unique(self.response_coordinate).shape[0]*np.unique(self.reference_coordinate).shape[0]*np.unique(self.abscissa).shape[0]):
5639 raise ValueError('The supplied array must be a full cross power spectral density matrix')
5641 physical_coordinate = np.unique(self.response_coordinate)
5642 original_spectra_ordinate = np.moveaxis(self[outer_product(physical_coordinate, physical_coordinate)].ordinate, -1, 0)
5644 if invert_transformation:
5645 if not np.all(np.unique(transformation.row_coordinate) == physical_coordinate):
5646 raise ValueError('The physical coordinates in the transformation do no match the spectra')
5647 transformed_coordinate = np.unique(transformation.column_coordinate)
5648 transformation_matrix = np.linalg.pinv(transformation[physical_coordinate, transformed_coordinate])
5649 elif not invert_transformation:
5650 if not np.all(np.unique(transformation.column_coordinate) == physical_coordinate):
5651 raise ValueError('The physical coordinates in the transformation do no match the spectra')
5652 transformed_coordinate = np.unique(transformation.row_coordinate)
5653 transformation_matrix = transformation[transformed_coordinate, physical_coordinate]
5655 if transformation_matrix.ndim == 2:
5656 transformation_matrix = transformation_matrix[np.newaxis,...] # this ensures the transpose in the next line works
5658 transformed_spectra_ordinate = transformation_matrix @ original_spectra_ordinate @ np.transpose(transformation_matrix.conj(), (0, 2, 1))
5660 return data_array(FunctionTypes.POWER_SPECTRAL_DENSITY, self.ravel().abscissa[0], np.moveaxis(transformed_spectra_ordinate, 0, -1),
5661 outer_product(transformed_coordinate, transformed_coordinate))
5663 def plot_magnitude_coherence_phase(self, compare_data=None, plot_axes=False,
5664 sharex=True, sharey=True, logx=False,
5665 logy=True,
5666 magnitude_plot_kwargs={},
5667 coherence_plot_kwargs={},
5668 angle_plot_kwargs={},
5669 figure_kwargs={}):
5670 """
5671 Plots the magnitude, coherence, and phase of a CPSD matrix.
5673 Coherence is plotted on the upper triangle, phase on the lower triangle,
5674 and magnitude on the diagonal.
5676 Parameters
5677 ----------
5678 compare_data : PowerSpectralDensityArray, optional
5679 An optional dataset to compare against. The default is None.
5680 plot_axes : bool, optional
5681 If True, axes tick labels will be plotted. If false, the plots will
5682 be pushed right against one another without room for labels.
5683 The default is False.
5684 sharex : bool, optional
5685 If True, all plots will share the same range on the X axis. The
5686 default is True.
5687 sharey : bool, optional
5688 If true, all plots of the same type will share the same range on the
5689 Y axis. The default is True.
5690 logx : bool, optional
5691 If true, the x-axis will be logarithmic. The default is False.
5692 logy : bool, optional
5693 If true, the y-axis on magnitude plots will be logrithmic. The
5694 default is True.
5695 magnitude_plot_kwargs : dict, optional
5696 Optional keywards to use when plotting magnitude. The default is {}.
5697 coherence_plot_kwargs : dict, optional
5698 Optional keywards to use when plotting coherence. The default is {}.
5699 angle_plot_kwargs : dict, optional
5700 Optional keywards to use when plotting phase. The default is {}.
5701 figure_kwargs : dict, optional
5702 Optional keywards to use when creating the figure. The default is {}.
5704 Returns
5705 -------
5706 None.
5708 """
5709 fig = plt.figure(**figure_kwargs)
5710 reshaped_array = self.reshape_to_matrix()
5711 coherence = reshaped_array.coherence()
5712 phase = reshaped_array.angle()
5713 if compare_data is not None:
5714 reshaped_compare_data = compare_data.reshape_to_matrix()
5715 compare_coherence = reshaped_compare_data.coherence()
5716 phase_compare = reshaped_compare_data.angle()
5717 ax = {}
5718 gs = GridSpec(*reshaped_array.shape, fig,
5719 wspace=None if plot_axes else 0,
5720 hspace=None if plot_axes else 0)
5721 for (i, j), function in reshaped_array.ndenumerate():
5722 if i == j:
5723 if ((not sharex) and (not sharey)) or i == 0:
5724 ax[i, j] = fig.add_subplot(gs[i, j])
5725 elif (not sharex) and sharey and (i > 0):
5726 ax[i, j] = fig.add_subplot(gs[i, j], sharey=ax[0, 0])
5727 elif sharex and (not sharey) and (i > 0):
5728 ax[i, j] = fig.add_subplot(gs[i, j], sharex=ax[0, 0])
5729 else:
5730 ax[i, j] = fig.add_subplot(gs[i, j], sharex=ax[0, 0],
5731 sharey=ax[0, 0])
5732 ax[i, j].plot(function.abscissa, np.abs(function.ordinate),
5733 'r', **magnitude_plot_kwargs)
5734 if compare_data is not None:
5735 ax[i, j].plot(reshaped_compare_data[i, j].abscissa,
5736 np.abs(reshaped_compare_data[i, j].ordinate),
5737 color=[1.0, .5, .5], **magnitude_plot_kwargs)
5738 if logy:
5739 ax[i, j].set_yscale('log')
5740 if i > j:
5741 ax[i, j] = fig.add_subplot(
5742 gs[i, j],
5743 sharex=ax[0, 0] if (i > 0) and sharex else None,
5744 sharey=ax[1, 0] if (i > 1) and sharey else None)
5745 ax[i, j].plot(function.abscissa, phase[i, j].ordinate,
5746 'g', **angle_plot_kwargs)
5747 if compare_data is not None:
5748 ax[i, j].plot(phase_compare[i, j].abscissa,
5749 phase_compare[i, j].ordinate,
5750 color=[0, 1, 0], **angle_plot_kwargs)
5751 ax[i, j].set_ylim(-np.pi, np.pi)
5752 if i < j:
5753 ax[i, j] = fig.add_subplot(
5754 gs[i, j],
5755 sharex=ax[0, 0] if (j > 0) and sharex else None,
5756 sharey=ax[0, 1] if (j > 1) and sharey else None)
5757 ax[i, j].plot(function.abscissa, coherence[i, j].ordinate,
5758 'b', **coherence_plot_kwargs)
5759 if compare_data is not None:
5760 ax[i, j].plot(compare_coherence[i, j].abscissa,
5761 compare_coherence[i, j].ordinate,
5762 color=[0.5, 0.5, 1.0], **coherence_plot_kwargs)
5763 ax[i, j].set_ylim(0, 1)
5764 if logx:
5765 ax[i, j].set_xscale('log')
5766 if j == 0:
5767 ax[i, j].set_ylabel(str(function.response_coordinate))
5768 if i == reshaped_array.shape[0]-1:
5769 ax[i, j].set_xlabel(str(function.reference_coordinate))
5770 if not plot_axes:
5771 ax[i, j].set_yticklabels([])
5772 ax[i, j].set_xticklabels([])
5773 ax[i, j].tick_params(axis='x', direction='in')
5774 ax[i, j].tick_params(axis='y', direction='in')
5776 def to_rattlesnake_specification(self, filename=None,
5777 coordinate_order=None,
5778 min_frequency=None,
5779 max_frequency=None,
5780 upper_warning_db=None,
5781 lower_warning_db=None,
5782 upper_abort_db=None,
5783 lower_abort_db=None,
5784 upper_warning_psd=None,
5785 lower_warning_psd=None,
5786 upper_abort_psd=None,
5787 lower_abort_psd=None):
5788 if coordinate_order is not None:
5789 coordinate_array = outer_product(coordinate_order)
5790 reshaped_data = self[coordinate_array]
5791 else:
5792 if self.ndim != 2:
5793 raise ValueError('CPSD Matrix must be 2D to transform to rattlesnake specification')
5794 if self.shape[0] != self.shape[1]:
5795 raise ValueError('CPSD Matrix must be square')
5796 if not np.all(self.coordinate[..., 0] == self.coordinate[..., 1].T):
5797 raise ValueError('Row and column coordinates of the CPSD matrix are not ordered identically')
5798 reshaped_data = self
5799 coordinate_array = reshaped_data.coordinate
5800 if min_frequency is not None or max_frequency is not None:
5801 if min_frequency is None:
5802 min_frequency = -np.inf
5803 if max_frequency is None:
5804 max_frequency = np.inf
5805 reshaped_data = reshaped_data.extract_elements_by_abscissa(min_frequency, max_frequency)
5806 out_dict = dict(
5807 f=reshaped_data[0, 0].abscissa,
5808 cpsd=np.moveaxis(reshaped_data.ordinate, -1, 0),
5809 coordinate=coordinate_array.view(np.ndarray))
5810 if upper_warning_db is not None:
5811 out_dict['warning_upper'] = np.einsum('ijj->ij', out_dict['cpsd']*db2scale(upper_warning_db)**2).real
5812 if lower_warning_db is not None:
5813 out_dict['warning_lower'] = np.einsum('ijj->ij', out_dict['cpsd']*db2scale(lower_warning_db)**2).real
5814 if upper_abort_db is not None:
5815 out_dict['abort_upper'] = np.einsum('ijj->ij', out_dict['cpsd']*db2scale(upper_abort_db)**2).real
5816 if lower_abort_db is not None:
5817 out_dict['abort_lower'] = np.einsum('ijj->ij', out_dict['cpsd']*db2scale(lower_abort_db)**2).real
5818 if upper_warning_psd is not None:
5819 signal = upper_warning_psd
5820 reshaped_signal = signal[np.einsum('iij->ij', coordinate_array)]
5821 if min_frequency is not None or max_frequency is not None:
5822 reshaped_signal = reshaped_signal.extract_elements_by_abscissa(min_frequency, max_frequency)
5823 if np.any(np.einsum('jji->ji', reshaped_data.abscissa) != reshaped_signal.abscissa):
5824 raise ValueError('Abscissa specified by upper warning signal is not equal to the specification signal')
5825 out_dict['warning_upper'] = np.moveaxis(reshaped_signal.ordinate, -1, 0).real
5826 if lower_warning_psd is not None:
5827 signal = lower_warning_psd
5828 reshaped_signal = signal[np.einsum('iij->ij', coordinate_array)]
5829 if min_frequency is not None or max_frequency is not None:
5830 reshaped_signal = reshaped_signal.extract_elements_by_abscissa(min_frequency, max_frequency)
5831 if np.any(np.einsum('jji->ji', reshaped_data.abscissa) != reshaped_signal.abscissa):
5832 raise ValueError('Abscissa specified by lower warning signal is not equal to the specification signal')
5833 out_dict['warning_lower'] = np.moveaxis(reshaped_signal.ordinate, -1, 0).real
5834 if upper_abort_psd is not None:
5835 signal = upper_abort_psd
5836 reshaped_signal = signal[np.einsum('iij->ij', coordinate_array)]
5837 if min_frequency is not None or max_frequency is not None:
5838 reshaped_signal = reshaped_signal.extract_elements_by_abscissa(min_frequency, max_frequency)
5839 if np.any(np.einsum('jji->ji', reshaped_data.abscissa) != reshaped_signal.abscissa):
5840 raise ValueError('Abscissa specified by upper abort signal is not equal to the specification signal')
5841 out_dict['abort_upper'] = np.moveaxis(reshaped_signal.ordinate, -1, 0).real
5842 if lower_abort_psd is not None:
5843 signal = lower_abort_psd
5844 reshaped_signal = signal[np.einsum('iij->ij', coordinate_array)]
5845 if min_frequency is not None or max_frequency is not None:
5846 reshaped_signal = reshaped_signal.extract_elements_by_abscissa(min_frequency, max_frequency)
5847 if np.any(np.einsum('jji->ji', reshaped_data.abscissa) != reshaped_signal.abscissa):
5848 raise ValueError('Abscissa specified by lower abort signal is not equal to the specification signal')
5849 out_dict['abort_lower'] = np.moveaxis(reshaped_signal.ordinate, -1, 0).real
5850 if filename is not None:
5851 np.savez(filename, **out_dict)
5852 return out_dict
5854 def bandwidth_average(self,band_lb,band_ub):
5855 """
5856 Integrates the PSD over frequency to get the power spectrum for each
5857 frequency bin (line)
5859 Parameters
5860 ----------
5861 band_lb : ndarray
5862 (n_bands,1) array of bandwidth lower bounds
5863 band_ub : ndarray
5864 (n_bands,1) array of bandwidth upper bounds
5866 Returns
5867 -------
5868 PowerSpectralDensityArray with abscissa given by the mean of band_lb
5869 and band_ub
5871 Notes
5872 -------
5873 Determines which freq bins (lines) contribute to each band. Contribute
5874 means the freq bin is at least partially within the band limits
5876 The portion of the bin which contributes to the band is computed based
5877 multiplied by the fraction of the contributing frequency to get how
5878 much bin PS adds to the band PS
5879 """
5880 # Process inputs
5881 if self.ordinate.ndim ==2:
5882 freq = self.abscissa[0,:]
5883 ein_str = 'jk,lk->lj'
5884 else:
5885 freq = self.abscissa[0,0,:]
5886 ein_str = 'jk,lmk->lmj'
5888 band_lb, band_ub = [band_lb.flatten(),band_ub.flatten()]
5889 band_lb,band_ub = [ band_lb[:,np.newaxis] , band_ub[:,np.newaxis] ]
5890 df = freq[2] - freq[1]
5891 hlf_bin = df/2
5892 if np.abs(np.diff(freq)-df).max() > 1e-12:
5893 ValueError('Frequencies are not evenly spaced')
5895 # Determine matrix A s.t. A_jk PSD_lmk = PSDav_lmj
5896 bandwidths = band_ub-band_lb
5897 bin_map_lb = np.maximum(freq-hlf_bin,band_lb) # LB of overlap for each bin/band combo
5898 bin_map_ub = np.minimum(freq+hlf_bin,band_ub) # UB of overlap for each bin/band combo
5900 bin_to_band = (bin_map_ub-bin_map_lb)/df
5901 bin_to_band = np.maximum(bin_to_band,np.zeros(bin_to_band.shape))
5903 # Get PSD
5904 psd_ave = np.einsum(ein_str,bin_to_band,df*self.ordinate)
5906 psd_ave = psd_ave/(band_ub[:,0]-band_lb[:,0])
5907 freqs = np.concatenate( (band_lb,band_ub) ,1).mean(1)
5909 return data_array(FunctionTypes.POWER_SPECTRAL_DENSITY,freqs,
5910 psd_ave,self.coordinate,self.comment1,self.comment2,self.comment3,
5911 self.comment4,self.comment5)
5914def power_spectral_density_array(abscissa,ordinate,coordinate,
5915 comment1='',comment2='',
5916 comment3='',comment4='',comment5=''):
5917 """
5918 Helper function to create a PowerSpectralDensityArray object.
5920 All input arguments to this function are allowed to broadcast to create the
5921 final data in the PowerSpectralDensityArray object.
5923 Parameters
5924 ----------
5925 abscissa : np.ndarray
5926 Numpy array specifying the abscissa of the function
5927 ordinate : np.ndarray
5928 Numpy array specifying the ordinate of the function
5929 coordinate : CoordinateArray
5930 Coordinate for each data in the data array
5931 comment1 : np.ndarray, optional
5932 Comment used to describe the data in the data array. The default is ''.
5933 comment2 : np.ndarray, optional
5934 Comment used to describe the data in the data array. The default is ''.
5935 comment3 : np.ndarray, optional
5936 Comment used to describe the data in the data array. The default is ''.
5937 comment4 : np.ndarray, optional
5938 Comment used to describe the data in the data array. The default is ''.
5939 comment5 : np.ndarray, optional
5940 Comment used to describe the data in the data array. The default is ''.
5942 Returns
5943 -------
5944 obj : PowerSpectralDensityArray
5945 The constructed PowerSpectralDensityArray object
5946 """
5947 return data_array(FunctionTypes.POWER_SPECTRAL_DENSITY,abscissa,ordinate,
5948 coordinate, comment1,comment2,comment3,comment4,comment5)
5950class PowerSpectrumArray(NDDataArray):
5951 """Data array used to store power spectra arrays"""
5952 def __new__(subtype, shape, nelements, buffer=None, offset=0,
5953 strides=None, order=None):
5954 obj = super().__new__(subtype, shape, nelements, 2, 'complex128', buffer, offset, strides, order)
5955 return obj
5957 @property
5958 def function_type(self):
5959 """
5960 Returns the function type of the data array
5961 """
5962 return FunctionTypes.AUTOSPECTRUM
5965def power_spectrum_array(abscissa,ordinate,coordinate,
5966 comment1='',comment2='',
5967 comment3='',comment4='',comment5=''):
5968 """
5969 Helper function to create a PowerSpectrumArray object.
5971 All input arguments to this function are allowed to broadcast to create the
5972 final data in the PowerSpectrumArray object.
5974 Parameters
5975 ----------
5976 abscissa : np.ndarray
5977 Numpy array specifying the abscissa of the function
5978 ordinate : np.ndarray
5979 Numpy array specifying the ordinate of the function
5980 coordinate : CoordinateArray
5981 Coordinate for each data in the data array
5982 comment1 : np.ndarray, optional
5983 Comment used to describe the data in the data array. The default is ''.
5984 comment2 : np.ndarray, optional
5985 Comment used to describe the data in the data array. The default is ''.
5986 comment3 : np.ndarray, optional
5987 Comment used to describe the data in the data array. The default is ''.
5988 comment4 : np.ndarray, optional
5989 Comment used to describe the data in the data array. The default is ''.
5990 comment5 : np.ndarray, optional
5991 Comment used to describe the data in the data array. The default is ''.
5993 Returns
5994 -------
5995 obj : PowerSpectrumArray
5996 The constructed PowerSpectrumArray object
5997 """
5998 return data_array(FunctionTypes.AUTOSPECTRUM,abscissa,ordinate,
5999 coordinate, comment1,comment2,comment3,comment4,comment5)
6001class TransferFunctionArray(NDDataArray):
6002 """Data array used to store transfer functions (for example FRFs)"""
6003 def __new__(subtype, shape, nelements, buffer=None, offset=0,
6004 strides=None, order=None):
6005 obj = super().__new__(subtype, shape, nelements, 2, 'complex128', buffer, offset, strides, order)
6006 return obj
6008 @staticmethod
6009 def from_time_data(reference_data: TimeHistoryArray,
6010 response_data: TimeHistoryArray,
6011 samples_per_average: int = None,
6012 overlap: float = 0.0, method: str = 'H1',
6013 window=np.array((1.0,)), return_model_data = False,
6014 **timedata2frf_kwargs):
6015 """
6016 Computes a transfer function from reference and response time histories
6018 Parameters
6019 ----------
6020 reference_data : TimeHistoryArray
6021 Time data to be used as a reference
6022 response_data : TimeHistoryArray
6023 Time data to be used as responses
6024 samples_per_average : int, optional
6025 Number of samples used to split up the signals into averages. The
6026 default is None, meaning the data is treated as a single measurement
6027 frame.
6028 overlap : float, optional
6029 The overlap as a fraction of the frame (e.g. 0.5 specifies 50% overlap).
6030 The default is 0.0, meaning no overlap is used.
6031 method : str, optional
6032 The method for creating the frequency response function. 'H1' is
6033 default if not specified. samples_per_average, overlap, and window
6034 are not used if method=='LRM'.
6035 window : np.ndarray or str, optional
6036 A 1D ndarray with length samples_per_average that specifies the
6037 coefficients of the window. No window is applied if not specified.
6038 If a string is specified, then the window will be obtained from scipy.
6039 **timedata2frf_kwargs : various
6040 Additional keyword arguments that may be passed into the
6041 timedata2frf function in sdynpy.frf. If method=='LRM', see also
6042 frf_local_model in sdynpy.lrm for more options.
6045 Raises
6046 ------
6047 ValueError
6048 Raised if reference and response functions do not have consistent
6049 abscissa
6051 Returns
6052 -------
6053 TransferFunctionArray
6054 A transfer function array computed from the specified references and
6055 responses.
6057 """
6058 ref_data = reference_data.flatten()
6059 res_data = response_data.flatten()
6060 ref_ord = ref_data.ordinate
6061 res_ord = res_data.ordinate
6062 if not np.allclose(ref_data[0].abscissa,
6063 res_data[0].abscissa):
6064 raise ValueError('Reference and Response Data should have identical abscissa spacing!')
6065 dt = np.mean(np.diff(ref_data[0].abscissa))
6066 if return_model_data:
6067 freq,frf,model_data = timedata2frf(ref_ord, res_ord, dt, samples_per_average,
6068 overlap, method, window, return_model_data=True,
6069 **timedata2frf_kwargs)
6070 else:
6071 freq,frf = timedata2frf(ref_ord, res_ord, dt, samples_per_average,
6072 overlap, method, window, return_model_data=False,
6073 **timedata2frf_kwargs)
6074 # Now construct the transfer function array
6075 coordinate = outer_product(res_data.coordinate.flatten(),
6076 ref_data.coordinate.flatten())
6077 if return_model_data:
6078 model_data = (model_data['model_selected']==len(model_data['modelset'])-1).mean()
6079 model_data = 'Highest order model selected in ' + str(round(model_data*100,1)) + '% of bins.'
6080 return data_array(FunctionTypes.FREQUENCY_RESPONSE_FUNCTION,
6081 freq, np.moveaxis(frf, 0, -1), coordinate,
6082 comment1=model_data)
6083 else:
6084 return data_array(FunctionTypes.FREQUENCY_RESPONSE_FUNCTION,
6085 freq, np.moveaxis(frf, 0, -1), coordinate)
6087 def ifft(self, norm="backward", odd_num_samples = False,
6088 **scipy_irfft_kwargs):
6089 """
6090 Converts frequency response functions to impulse response functions via an
6091 inverse fourier transform.
6093 Paramters
6094 ---------
6095 norm : str, optional
6096 The type of normalization applied to the fft computation.
6097 odd_num_samples : bool, optional
6098 If True, then it is assumed that the output signal has an odd
6099 number of samples, meaning the signal will have a length of
6100 2*(m-1)+1 where m is the number of frequency lines. Otherwise, the
6101 default value of 2*(m-1) is used, assuming an even signal. This is
6102 ignored if num_samples is specified.
6103 scipy_irfft_kwargs :
6104 Additional keywords that will be passed to SciPy's irfft function.
6106 Raises
6107 ------
6108 Warning
6109 Raised if the transfer function array does not have evenly spaced
6110 frequency data in the 0-maximum frequency range, but appears to have been
6111 high pass filtered.
6112 ValueError
6113 Raised if the transfer function array does not have evenly spaced
6114 frequency data in the 0-maximum frequency range and it does not appear
6115 to have been high pass filtered.
6117 Returns
6118 -------
6119 ImpulseResponseFunctionArray
6120 The impulse response function array computed from the transfer function
6121 array.
6122 """
6124 df = self.abscissa_spacing
6125 min_freq = self.abscissa.min()
6126 if min_freq % df > 0.01*df:
6127 raise ValueError('Frequency bins do not line up with zero. Cannot compute rfft bins.')
6128 first_frequency_bin = int(np.round(self.abscissa.min()/df))
6129 padding = np.zeros(self.ordinate.shape[:-1]+(first_frequency_bin,),self.ordinate.dtype)
6130 if padding.shape[-1] > 0:
6131 warnings.warn(
6132 'The FRFs are missing some low frequency data'
6133 + ' and it is assumed that this is due to some high pass cut-off.'
6134 + ' The data is being zero padded at low frequencies.')
6135 num_elements = first_frequency_bin+self.num_elements
6137 if odd_num_samples:
6138 num_samples = 2*(num_elements-1)+1
6139 else:
6140 num_samples = 2*(num_elements-1)
6142 # Organizing the FRFs for the ifft, this handles the zero padding if low frequency
6143 # data is missing
6144 ordinate = np.concatenate((padding,self.ordinate),axis=-1)
6145 irfft = scipyfft.irfft(ordinate, axis=-1, n=num_samples, norm=norm,
6146 **scipy_irfft_kwargs)
6148 # Building the time vectors
6149 dt = 1 / (self.abscissa.max()*num_samples/np.floor(num_samples/2))
6150 time_vector = dt * np.arange(num_samples)
6152 return data_array(FunctionTypes.IMPULSE_RESPONSE_FUNCTION, time_vector, irfft, self.coordinate,
6153 self.comment1, self.comment2, self.comment3, self.comment4, self.comment5)
6155 def enforce_causality(self, method='exponential_taper',
6156 window_parameter=None,
6157 end_of_ringdown=None):
6158 """
6159 Enforces causality on the frequency response function via a conversion
6160 to a impulse response function, applying a cutoff window, then converting
6161 back to a frequency response function.
6163 Parameters
6164 ----------
6165 method : str
6166 The window type that is applied to the data to enforce causality.
6167 Note that these options are not necessarily traditional windows
6168 (used for data processing). The current options are:
6169 - exponential_taper (default) - this applies a exponential taper
6170 to the end of a boxcar window on the IRF.
6171 - boxcar - this applies a boxcar (uniform) window to the IRF
6172 with the cuttoff at a specified sample.
6173 - exponential - this applies an exponential window to the IRF
6174 with the 40 dB down point (of the window) at a specified sample.
6175 Care should be taken when using this window type, since it can
6176 lead to erratic behavior.
6177 window_parameter : int, optional
6178 This is a parameter that defines the window for the causality
6179 enforcement. Methods exist to define this parameter automatically
6180 if it isn't provided. The behaviors for the options are:
6181 - boxcar - the window_paramter is the sample after which the
6182 IRF is set to zero. It is the same as the end_of_ringdown
6183 parameter for this window type.
6184 - exponential - the window_parameter is where the 40 dB down
6185 point is for the window. It is the same as the end_of_ringdown
6186 parameter for this window type.
6187 - exponential_taper - the window_parameter is where the end point
6188 of the window (where the amplitude is 0.001), as defined by the
6189 number of samples after the uniform section of the window.
6190 end_of_ringdown : int, optional
6191 This is a parameter that defines the end of the uniform section of
6192 the exponetional_taper window. It is not used for either the boxcar
6193 or exponential window. Methods exist to define this parameter
6194 automatically if it isn't provided.
6196 Returns
6197 -------
6198 TransferFunctionArray
6199 The FRF with causality enforced.
6201 Notes
6202 -----
6203 This is a wrapper around the method in the impulse response function class
6204 and it may be wiser to use that function instead.
6206 Although optional, it is best practice for the user to supply a parameter
6207 for the end_of_ringdown variable if the "exponential_taper" method is
6208 being used or a window_parameter if the "exponential" or "boxcar" methods
6209 are being used. The code will attempt to find the end of the ring-down in
6210 the IRF and use use that as the end_of_ringdown parameter for the
6211 "exponential_taper" window or the window_parameter for the exponential and
6212 boxcar windows.
6214 It is not suggested that the user provide a window_paramter if the
6215 "exponential_taper" method is being used, since the default is likely the
6216 most logical choice.
6218 References
6219 ----------
6220 .. [1] Zvonkin, M. (2015). Methods for checking and enforcing physical quality of linear electrical network models
6221 [Masters Theses, Missouri University of Science and Technology], Missouri S&T Scholars' Mine, https://scholarsmine.mst.edu/masters_theses/7490/
6223 """
6224 irfs = self.ifft()
6225 causal_irfs = irfs.enforce_causality(method=method,
6226 window_parameter=window_parameter,
6227 end_of_ringdown=end_of_ringdown)
6228 return causal_irfs.fft()
6230 def svd(self, full_matrices=True, compute_uv=True, as_matrix=True):
6231 """
6232 Compute the SVD of the provided FRF matrix
6234 Parameters
6235 ----------
6236 full_matrices : bool, optional
6237 This is an optional input for np.linalg.svd, the default for this
6238 function is true (which differs from the np.linalg.svd function).
6239 compute_uv : bool, optional
6240 This is an optional input for np.linalg.svd, the default for this
6241 function is true (which differs from the np.linalg.svd function).
6242 as_matrix : bool, optional
6243 If True, matrices are returned as a SDynPy Matrix class with named
6244 rows and columns. Otherwise, a simple numpy array is returned
6246 Returns
6247 -------
6248 u : ndarray or Matrix
6249 Left hand singular vectors, sized [..., num_responses, num_responses].
6250 Only returned when compute_uv is True.
6251 s : ndarray or Matrix
6252 Singular values, sized [..., num_references]
6253 vh : ndarray or Matrix
6254 Right hand singular vectors, sized [..., num_references, num_references].
6255 Only returned when compute_uv is True.
6256 """
6257 frf = self.reshape_to_matrix()
6258 frfOrd = np.moveaxis(frf.ordinate, -1, 0)
6259 if compute_uv:
6260 u, s, vh = np.linalg.svd(frfOrd, full_matrices, compute_uv)
6261 if as_matrix:
6262 u = matrix(u, frf[:, 0].response_coordinate,
6263 coordinate_array(np.arange(u.shape[-1])+1, 0))
6264 s = matrix(s[:, np.newaxis]*np.eye(s.shape[-1]), coordinate_array(np.arange(s.shape[-1])+1, 0),
6265 coordinate_array(np.arange(s.shape[-1])+1, 0))
6266 vh = matrix(vh, coordinate_array(np.arange(vh.shape[-2])+1, 0),
6267 frf[0, :].reference_coordinate,
6268 )
6269 return u, s, vh
6270 else:
6271 s = np.linalg.svd(frfOrd, full_matrices, compute_uv)
6272 if as_matrix:
6273 s = matrix(s[:, np.newaxis]*np.eye(s.shape[-1]), coordinate_array(np.arange(s.shape[-1])+1, 0),
6274 coordinate_array(np.arange(s.shape[-1])+1, 0))
6275 return s
6277 def compute_mif(self, mif_type, *mif_args, **mif_kwargs):
6278 """
6279 Compute a mode indicator functions from the transfer functions
6281 Parameters
6282 ----------
6283 mif_type : str
6284 Mode indicator function type, one of 'cmif','nmif', or 'mmif'
6285 *mif_args : list
6286 Arguments passed to the compute_*mif function
6287 **mif_kwargs : dict
6288 Keyword arguments passed to the compute_*mif function
6290 Raises
6291 ------
6292 ValueError
6293 If an invalid mif name is provided.
6295 Returns
6296 -------
6297 ModeIndicatorFunctionArray
6298 Mode indicator function
6300 """
6301 if mif_type.lower() == 'cmif':
6302 return self.compute_cmif(*mif_args, **mif_kwargs)
6303 elif mif_type.lower() == 'mmif':
6304 return self.compute_mmif(*mif_args, **mif_kwargs)
6305 elif mif_type.lower() == 'nmif':
6306 return self.compute_nmif(*mif_args, **mif_kwargs)
6307 else:
6308 raise ValueError('Invalid MIF type, must be one of cmif, mmif, or nmif')
6310 def compute_cmif(self, part='both', tracking=None):
6311 """
6312 Computes a complex mode indicator function from the
6313 TransferFunctionArray
6315 Parameters
6316 ----------
6317 part : str, optional
6318 Specifies which part(s) of the transfer functions are used to
6319 compute the CMIF. Can be 'real', 'imag', or 'both'. The default
6320 is 'both'.
6321 tracking : str or None, optional
6322 Specifies if any singular value tracking should be used. Can be
6323 'left' or 'right'. The default is None.
6325 Raises
6326 ------
6327 ValueError
6328 Raised if an invalid tracking is specified
6330 Returns
6331 -------
6332 output_array : ModeIndicatorFunctionArray
6333 Complex Mode Indicator Function
6334 """
6335 matrix_form = self.reshape_to_matrix()
6336 ordinate = np.moveaxis(matrix_form.ordinate, -1, 0)
6337 if part.lower() == 'imag' or part.lower() == 'imaginary':
6338 ordinate = ordinate.imag
6339 if part.lower() == 'real':
6340 ordinate = ordinate.real
6341 u, s, vh = np.linalg.svd(ordinate, full_matrices=False, compute_uv=True)
6342 v = vh.conjugate().transpose(0, 2, 1)
6343 if tracking is not None:
6344 u_unshuffled = np.zeros(u.shape, dtype=u.dtype)
6345 s_unshuffled = np.zeros(s.shape, dtype=s.dtype)
6346 v_unshuffled = np.zeros(v.shape, dtype=v.dtype)
6347 if tracking == 'left':
6348 u_unshuffled[0] = u[0]
6349 s_unshuffled[0] = s[0]
6350 v_unshuffled[0] = v[0]
6351 for line in range(s.shape[0]):
6352 if line == 0:
6353 continue
6354 previous_u = u_unshuffled[line - 1]
6355 this_u = u[line]
6356 # np.linalg.norm(previous_u-this_u,axis=0)
6357 comparison_matrix = mac(previous_u, this_u)
6358 current_sorting = np.argmax(comparison_matrix, axis=1)
6359 u_unshuffled[line] = u[line][:, current_sorting]
6360 s_unshuffled[line] = s[line][current_sorting]
6361 v_unshuffled[line] = v[line][:, current_sorting]
6362 elif tracking == 'right':
6363 raise NotImplementedError(
6364 'Tracking by right singular vector is not yet implemented')
6365 else:
6366 raise ValueError('tracking must be None or "left" or "right"')
6367 u = u_unshuffled
6368 v = v_unshuffled
6369 s = s_unshuffled
6370 output_array = ModeIndicatorFunctionArray((s.shape[1],), s.shape[0])
6371 output_array.response_coordinate = coordinate_array(
6372 np.arange(s.shape[1]) + 1, 0)
6373 output_array.ordinate = s.T
6374 output_array.abscissa = self.abscissa[(
6375 0,) * (self.abscissa.ndim - 1) + (slice(None, None, None),)]
6376 return output_array
6378 def compute_nmif(self, part='real'):
6379 """
6380 Computes a normal mode indicator function from the
6381 TransferFunctionArray
6383 Parameters
6384 ----------
6385 part : str, optional
6386 Specifies which part(s) of the transfer functions are used to
6387 compute the NMIF. Can be 'real' or 'imag'. The default
6388 is 'real'.
6390 Raises
6391 ------
6392 ValueError
6393 Raised if an invalid part is specified
6395 Returns
6396 -------
6397 output_array : ModeIndicatorFunctionArray
6398 Normal Mode Indicator Function
6399 """
6400 ordinate = self.flatten().ordinate
6401 if part == 'real':
6402 nmif = np.sum(np.abs(ordinate) * np.abs(np.real(ordinate)),
6403 axis=0) / np.sum(np.abs(ordinate)**2, axis=0)
6404 elif part == 'imag':
6405 nmif = np.sum(np.abs(ordinate) * np.abs(np.imag(ordinate)),
6406 axis=0) / np.sum(np.abs(ordinate)**2, axis=0)
6407 else:
6408 raise ValueError('part must be "real" or "imag"')
6409 output_array = ModeIndicatorFunctionArray((), nmif.size)
6410 output_array.abscissa = self.abscissa[(
6411 0,) * (self.abscissa.ndim - 1) + (slice(None, None, None),)]
6412 output_array.ordinate = nmif
6413 output_array.response_coordinate = coordinate_array(1, 0)
6414 return output_array
6416 def compute_mmif(self, part='real', mass_matrix=None):
6417 """
6418 Computes a Multi Mode indicator function from the
6419 TransferFunctionArray
6421 Parameters
6422 ----------
6423 part : str, optional
6424 Specifies which part(s) of the transfer functions are used to
6425 compute the NMIF. Can be 'real' or 'imag'. The default
6426 is 'real'.
6427 mass_matrix : np.ndarray, optional
6428 Matrix used to compute the MMIF, Identity is used if not specified
6430 Raises
6431 ------
6432 ValueError
6433 Raised if an invalid part is specified
6435 Returns
6436 -------
6437 output_array : ModeIndicatorFunctionArray
6438 Multi Mode Indicator Function
6439 """
6440 rect_frf = self.reshape_to_matrix()
6441 ordinate = rect_frf.ordinate.transpose(2, 0, 1)
6442 real = np.real(ordinate)
6443 imag = np.imag(ordinate)
6444 if mass_matrix is None:
6445 mass_matrix = np.eye(ordinate.shape[1])
6446 A = real.transpose(0, 2, 1) @ mass_matrix @ real
6447 B = imag.transpose(0, 2, 1) @ mass_matrix @ imag
6448 mif_ordinate = np.zeros((ordinate.shape[0], ordinate.shape[-1]))
6449 if part == 'real':
6450 for index, (this_a, this_b) in enumerate(zip(A, B)):
6451 evalue = eigh(this_a, (this_a + this_b), eigvals_only=True)
6452 mif_ordinate[index] = evalue
6453 elif part == 'imag':
6454 for index, (this_a, this_b) in enumerate(zip(A, B)):
6455 evalue = eigh(this_b, (this_a + this_b), eigenvals_only=True)
6456 mif_ordinate[index] = evalue
6457 else:
6458 raise ValueError('part must be "real" or "imag"')
6459 output_array = ModeIndicatorFunctionArray(mif_ordinate.shape[-1], mif_ordinate.shape[0])
6460 output_array.abscissa = self.abscissa[(
6461 0,) * (self.abscissa.ndim - 1) + (slice(None, None, None),)]
6462 output_array.ordinate = mif_ordinate.T
6463 output_array.response_coordinate = coordinate_array(
6464 np.arange(mif_ordinate.shape[1]) + 1, 0)
6465 return output_array
6467 def plot_cond_num(self, number_retained_values=None, min_frequency=None, max_frequency=None):
6468 """
6469 Plots the condition number of the FRF matrix
6471 Parameters
6472 ----------
6473 min_freqency : float, optional
6474 Minimum frequency to plot. The default is None.
6475 max_frequency : float, optional
6476 Maximum frequency to plot. The default is None.
6478 Returns
6479 -------
6480 None.
6482 """
6483 freq = self.flatten().abscissa[0, :]
6484 s_frf = self.svd(full_matrices=False, compute_uv=False, as_matrix=False)
6485 cond_num = s_frf[..., 0]/s_frf[..., -1]
6486 figure, axis = plt.subplots(1, 1)
6487 axis.plot(freq, cond_num, label='Unmodified Condition Number')
6488 if number_retained_values is not None:
6489 cutoff = np.zeros_like(s_frf[:, 1])
6490 number_retained_values = np.asarray(number_retained_values, dtype=np.intc)
6491 if number_retained_values.size == 1:
6492 cutoff = s_frf[:, number_retained_values-1]
6493 else:
6494 for ii in range(len(number_retained_values)):
6495 cutoff[ii] = s_frf[ii, number_retained_values[ii]-1]
6496 axis.plot(freq, s_frf[..., 0]/cutoff, label='Modified Condition Number')
6497 axis.legend()
6498 axis.grid()
6499 axis.set_xlabel('Frequency (Hz)')
6500 axis.set_ylabel('Condition Number')
6501 axis.set_title('Condition Number of FRF Matrix')
6502 if min_frequency is not None:
6503 axis.set_xlim(left=min_frequency)
6504 cond_num = cond_num[np.argmin(np.abs(freq-min_frequency)):]
6505 if max_frequency is not None:
6506 axis.set_xlim(right=max_frequency)
6507 cond_num = cond_num[:np.argmin(np.abs(freq-max_frequency))]
6508 axis.set_ylim(bottom=0, top=cond_num.max()*1.01)
6510 return figure, axis
6512 def plot_singular_values(self, rcond=None,
6513 condition_number=None,
6514 number_retained_values=None,
6515 regularization_parameter=None,
6516 min_frequency=None,
6517 max_frequency=None):
6518 """
6519 Plot the singular values of an FRF matrix with a visualization of the rcond tolerance
6521 Parameters
6522 ----------
6523 rcond : float or ndarray, optional
6524 Cutoff for small singular values. Implemented such that the cutoff is rcond*
6525 largest_singular_value (the same as np.linalg.pinv). This is to visualize the
6526 effect of rcond and is used for display purposes only.
6527 condition_number : float or ndarray, optional
6528 Condition number threshold for small singular values. The condition number
6529 is the reciprocal of rcond. This is to visualize the effect of condition
6530 number threshold and is used for display purposes only.
6531 number_retained_values : float or ndarray, optional
6532 Cutoff for small singular values a an integer value of number of values
6533 to retain. This is to visualize the effect of singular value truncation
6534 and is used for display purposes only.
6535 regularization_parameter: float or ndarray, optional
6536 Regularization parameter to compute the modified singular values. This is
6537 to visualize the effect of Tikhonov regularization and is used for display
6538 purposes only.
6539 min_frequency : float, optional
6540 Minimum frequency to plot
6541 max_frequency : float, optional
6542 Maximum frequency to plot
6543 """
6544 freq = self.flatten().abscissa[0, :]
6545 s_frf = self.svd(compute_uv=False, as_matrix=False)
6546 figure, axis = plt.subplots(1, 1)
6547 axis.semilogy(freq, s_frf)
6548 if rcond is not None:
6549 cutoff = s_frf[:, 0] * rcond
6550 axis.semilogy(freq, cutoff, color='k', linestyle='dashed', linewidth=3)
6551 if condition_number is not None:
6552 threshold = s_frf[:, 0] / condition_number
6553 cutoff = np.zeros_like(s_frf)
6554 for ii in range(threshold.size):
6555 number_values_above_cutoff = (s_frf[ii, :] > threshold[ii]).sum()
6556 cutoff[ii, number_values_above_cutoff:] = s_frf[ii, number_values_above_cutoff:]
6557 cutoff[cutoff == 0] = np.nan
6558 axis.semilogy(freq, cutoff, color='k', linestyle='dashed', linewidth=3)
6559 if number_retained_values is not None:
6560 cutoff = np.zeros_like(s_frf[:, 1])
6561 number_retained_values = np.asarray(number_retained_values, dtype=np.intc)
6562 if number_retained_values.size == 1:
6563 cutoff = s_frf[:, number_retained_values:]
6564 else:
6565 cutoff = np.zeros_like(s_frf)
6566 for ii in range(len(number_retained_values)):
6567 cutoff[ii, number_retained_values[ii]:] = s_frf[ii, number_retained_values[ii]:]
6568 cutoff[cutoff == 0] = np.nan
6569 axis.semilogy(freq, cutoff, color='k', linestyle='dashed', linewidth=3)
6570 if regularization_parameter is not None:
6571 s_modified = compute_tikhonov_modified_singular_values(s_frf, regularization_parameter)
6572 figure.gca().set_prop_cycle(None)
6573 axis.semilogy(freq, s_modified, linestyle='dotted', linewidth=4)
6574 axis.grid()
6575 axis.set_xlabel('Frequency (Hz)')
6576 axis.set_ylabel('Singular Values')
6577 axis.set_title('Singular Values of FRF Matrix')
6578 if min_frequency is not None:
6579 axis.set_xlim(left=min_frequency)
6580 s_frf = s_frf[np.argmin(np.abs(freq-min_frequency)):, :]
6581 if max_frequency is not None:
6582 axis.set_xlim(right=max_frequency)
6583 s_frf = s_frf[:np.argmin(np.abs(freq-max_frequency)), :]
6584 axis.set_ylim(bottom=s_frf.min()*0.9, top=s_frf.max()*1.1)
6586 return figure, axis
6588 def plot(self, one_axis=True, part = None, subplots_kwargs={},
6589 plot_kwargs={}, abscissa_markers = None,
6590 abscissa_marker_labels = None, abscissa_marker_type = 'vline',
6591 abscissa_marker_plot_kwargs = {}):
6592 """
6593 Plot the transfer functions
6595 Parameters
6596 ----------
6597 one_axis : bool, optional
6598 Set to True to plot all data on one axis. Set to False to plot
6599 data on multiple subplots. one_axis can also be set to a
6600 matplotlib axis to plot data on an existing axis. The default is
6601 True.
6602 part : str, optional
6603 The part of the FRF to plot. This can be, 'real', 'imag' or
6604 'imaginary', 'mag' or 'magnitude', or 'phase'. If not specified,
6605 magnitude and phase will be plotted if `one_axis` is True, and
6606 magnitude will be plotted if `one_axis` is False.
6607 subplots_kwargs : dict, optional
6608 Keywords passed to the matplotlib subplots function to create the
6609 figure and axes. The default is {}.
6610 plot_kwargs : dict, optional
6611 Keywords passed to the matplotlib plot function. The default is {}.
6612 abscissa_markers : ndarray, optional
6613 Array containing abscissa values to mark on the plot to denote
6614 significant events.
6615 abscissa_marker_labels : str or ndarray
6616 Array of strings to label the abscissa_markers with, or
6617 alternatively a format string that accepts index and abscissa
6618 inputs (e.g. '{index:}: {abscissa:0.2f}'). By default no label
6619 will be applied.
6620 abscissa_marker_type : str
6621 The type of marker to use. This can either be the string 'vline'
6622 or a valid matplotlib symbol specifier (e.g. 'o', 'x', '.').
6623 abscissa_marker_plot_kwargs : dict
6624 Additional keyword arguments used when plotting the abscissa label
6625 markers.
6627 Returns
6628 -------
6629 axis : matplotlib axis or array of axes
6630 On which the data were plotted
6632 """
6633 if abscissa_markers is not None:
6634 if abscissa_marker_labels is None:
6635 abscissa_marker_labels = ['' for value in abscissa_markers]
6636 elif isinstance(abscissa_marker_labels,str):
6637 abscissa_marker_labels = [abscissa_marker_labels.format(
6638 index = i, abscissa = v) for i,v in enumerate(abscissa_markers)]
6640 part_fns = {'imag':np.imag,
6641 'real':np.real,
6642 'mag':np.abs,
6643 'magnitude':np.abs,
6644 'phase':np.angle,
6645 'imaginary':np.imag}
6646 part_labels = {'imag':'Imaginary',
6647 'real':'Real',
6648 'mag':'Magnitude',
6649 'magnitude':'Magnitude',
6650 'phase':'Phase',
6651 'imaginary':'Imaginary'}
6652 part_yscale = {'imag':'linear',
6653 'real':'linear',
6654 'mag':'log',
6655 'magnitude':'log',
6656 'phase':'linear',
6657 'imaginary':'linear'}
6658 if one_axis is True:
6659 if part is None:
6660 figure, axis = plt.subplots(2, 1, **subplots_kwargs)
6661 lines = axis[0].plot(self.flatten().abscissa.T, np.angle(
6662 self.flatten().ordinate.T), **plot_kwargs)
6663 if abscissa_markers is not None:
6664 if abscissa_marker_type == 'vline':
6665 kwargs = {'color':'k'}
6666 kwargs.update(abscissa_marker_plot_kwargs)
6667 for value,label in zip(abscissa_markers,abscissa_marker_labels):
6668 axis[0].axvline(value, **kwargs)
6669 axis[0].annotate(label, xy = (value, axis[0].get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
6670 axis[0].callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
6671 else:
6672 for line in lines:
6673 x = line.get_xdata()
6674 y = line.get_ydata()
6675 marker_y = np.interp(abscissa_markers, x, y)
6676 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
6677 kwargs.update(abscissa_marker_plot_kwargs)
6678 axis[0].plot(abscissa_markers,marker_y,**kwargs)
6679 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
6680 axis[0].annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
6681 lines = axis[1].plot(self.flatten().abscissa.T, np.abs(
6682 self.flatten().ordinate.T), **plot_kwargs)
6683 axis[1].set_yscale('log')
6684 if abscissa_markers is not None:
6685 if abscissa_marker_type == 'vline':
6686 kwargs = {'color':'k'}
6687 kwargs.update(abscissa_marker_plot_kwargs)
6688 for value,label in zip(abscissa_markers,abscissa_marker_labels):
6689 axis[1].axvline(value, **kwargs)
6690 axis[1].annotate(label, xy = (value, axis[1].get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
6691 axis[1].callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
6692 else:
6693 for line in lines:
6694 x = line.get_xdata()
6695 y = line.get_ydata()
6696 marker_y = np.interp(abscissa_markers, x, y)
6697 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
6698 kwargs.update(abscissa_marker_plot_kwargs)
6699 axis[1].plot(abscissa_markers,marker_y,**kwargs)
6700 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
6701 axis[1].annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
6702 axis[0].set_ylabel('Phase')
6703 axis[1].set_ylabel('Amplitude')
6704 axis[1].set_xlabel('Frequency')
6706 else:
6707 figure, axis = plt.subplots(1, 1, **subplots_kwargs)
6708 lines = axis.plot(self.flatten().abscissa.T, part_fns[part](
6709 self.flatten().ordinate.T), **plot_kwargs)
6710 axis.set_yscale(part_yscale[part])
6711 axis.set_ylabel(part_labels[part])
6712 axis.set_xlabel('Frequency')
6713 if abscissa_markers is not None:
6714 if abscissa_marker_type == 'vline':
6715 kwargs = {'color':'k'}
6716 kwargs.update(abscissa_marker_plot_kwargs)
6717 for value,label in zip(abscissa_markers,abscissa_marker_labels):
6718 axis.axvline(value, **kwargs)
6719 axis.annotate(label, xy = (value, axis.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
6720 axis.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
6721 else:
6722 for line in lines:
6723 x = line.get_xdata()
6724 y = line.get_ydata()
6725 marker_y = np.interp(abscissa_markers, x, y)
6726 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
6727 kwargs.update(abscissa_marker_plot_kwargs)
6728 axis.plot(abscissa_markers,marker_y,**kwargs)
6729 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
6730 axis.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
6731 elif one_axis is False:
6732 ncols = int(np.floor(np.sqrt(self.size)))
6733 nrows = int(np.ceil(self.size / ncols))
6734 figure, axis = plt.subplots(nrows, ncols, **subplots_kwargs)
6735 if part is None:
6736 for i, (ax, (index, function)) in enumerate(zip(axis.flatten(), self.ndenumerate())):
6737 lines = ax.plot(function.abscissa.T, np.abs(function.ordinate.T), **plot_kwargs)
6738 ax.set_ylabel('/'.join([str(v) for i, v in function.coordinate.ndenumerate()]))
6739 ax.set_yscale('log')
6740 if abscissa_markers is not None:
6741 if abscissa_marker_type == 'vline':
6742 kwargs = {'color':'k'}
6743 kwargs.update(abscissa_marker_plot_kwargs)
6744 for value,label in zip(abscissa_markers,abscissa_marker_labels):
6745 ax.axvline(value, **kwargs)
6746 ax.annotate(label, xy = (value, ax.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
6747 ax.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
6748 else:
6749 for line in lines:
6750 x = line.get_xdata()
6751 y = line.get_ydata()
6752 marker_y = np.interp(abscissa_markers, x, y)
6753 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
6754 kwargs.update(abscissa_marker_plot_kwargs)
6755 ax.plot(abscissa_markers,marker_y,**kwargs)
6756 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
6757 ax.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
6758 else:
6759 for i, (ax, (index, function)) in enumerate(zip(axis.flatten(), self.ndenumerate())):
6760 lines = ax.plot(function.abscissa.T, part_fns[part](function.ordinate.T), **plot_kwargs)
6761 ax.set_ylabel('/'.join([str(v) for i, v in function.coordinate.ndenumerate()]))
6762 ax.set_yscale(part_yscale[part])
6763 if abscissa_markers is not None:
6764 if abscissa_marker_type == 'vline':
6765 kwargs = {'color':'k'}
6766 kwargs.update(abscissa_marker_plot_kwargs)
6767 for value,label in zip(abscissa_markers,abscissa_marker_labels):
6768 ax.axvline(value, **kwargs)
6769 ax.annotate(label, xy = (value, ax.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
6770 ax.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
6771 else:
6772 for line in lines:
6773 x = line.get_xdata()
6774 y = line.get_ydata()
6775 marker_y = np.interp(abscissa_markers, x, y)
6776 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
6777 kwargs.update(abscissa_marker_plot_kwargs)
6778 ax.plot(abscissa_markers,marker_y,**kwargs)
6779 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
6780 ax.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
6781 for ax in axis.flatten()[i + 1:]:
6782 ax.remove()
6783 else:
6784 axis = one_axis
6785 if part is None:
6786 try:
6787 lines = axis[0].plot(self.flatten().abscissa.T, np.angle(
6788 self.flatten().ordinate.T), **plot_kwargs)
6789 if abscissa_markers is not None:
6790 if abscissa_marker_type == 'vline':
6791 kwargs = {'color':'k'}
6792 kwargs.update(abscissa_marker_plot_kwargs)
6793 for value,label in zip(abscissa_markers,abscissa_marker_labels):
6794 axis[0].axvline(value, **kwargs)
6795 axis[0].annotate(label, xy = (value, axis[0].get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
6796 axis[0].callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
6797 else:
6798 for line in lines:
6799 x = line.get_xdata()
6800 y = line.get_ydata()
6801 marker_y = np.interp(abscissa_markers, x, y)
6802 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
6803 kwargs.update(abscissa_marker_plot_kwargs)
6804 axis[0].plot(abscissa_markers,marker_y,**kwargs)
6805 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
6806 axis[0].annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
6807 lines = axis[1].plot(self.flatten().abscissa.T, np.abs(
6808 self.flatten().ordinate.T), **plot_kwargs)
6809 if abscissa_markers is not None:
6810 if abscissa_marker_type == 'vline':
6811 kwargs = {'color':'k'}
6812 kwargs.update(abscissa_marker_plot_kwargs)
6813 for value,label in zip(abscissa_markers,abscissa_marker_labels):
6814 axis[1].axvline(value, **kwargs)
6815 axis[1].annotate(label, xy = (value, axis[1].get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
6816 axis[1].callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
6817 else:
6818 for line in lines:
6819 x = line.get_xdata()
6820 y = line.get_ydata()
6821 marker_y = np.interp(abscissa_markers, x, y)
6822 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
6823 kwargs.update(abscissa_marker_plot_kwargs)
6824 axis[1].plot(abscissa_markers,marker_y,**kwargs)
6825 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
6826 axis[1].annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
6827 except TypeError:
6828 lines = axis.plot(self.flatten().abscissa.T, np.abs(
6829 self.flatten().ordinate.T), **plot_kwargs)
6830 if abscissa_markers is not None:
6831 if abscissa_marker_type == 'vline':
6832 kwargs = {'color':'k'}
6833 kwargs.update(abscissa_marker_plot_kwargs)
6834 for value,label in zip(abscissa_markers,abscissa_marker_labels):
6835 axis.axvline(value, **kwargs)
6836 axis.annotate(label, xy = (value, axis.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
6837 axis.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
6838 else:
6839 for line in lines:
6840 x = line.get_xdata()
6841 y = line.get_ydata()
6842 marker_y = np.interp(abscissa_markers, x, y)
6843 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
6844 kwargs.update(abscissa_marker_plot_kwargs)
6845 axis.plot(abscissa_markers,marker_y,**kwargs)
6846 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
6847 axis.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
6848 else:
6849 lines = axis.plot(self.flatten().abscissa.T, part_fns[part](
6850 self.flatten().ordinate.T), **plot_kwargs)
6851 if abscissa_markers is not None:
6852 if abscissa_marker_type == 'vline':
6853 kwargs = {'color':'k'}
6854 kwargs.update(abscissa_marker_plot_kwargs)
6855 for value,label in zip(abscissa_markers,abscissa_marker_labels):
6856 axis.axvline(value, **kwargs)
6857 axis.annotate(label, xy = (value, axis.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
6858 axis.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
6859 else:
6860 for line in lines:
6861 x = line.get_xdata()
6862 y = line.get_ydata()
6863 marker_y = np.interp(abscissa_markers, x, y)
6864 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
6865 kwargs.update(abscissa_marker_plot_kwargs)
6866 axis.plot(abscissa_markers,marker_y,**kwargs)
6867 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
6868 axis.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
6869 return axis
6871 def plot_with_coherence(self, coherence, part = None, subplots_kwargs={}, plot_kwargs={}):
6872 axes = self.plot(one_axis=False,part=part,subplots_kwargs = subplots_kwargs,
6873 plot_kwargs = plot_kwargs)
6874 # Get the corresponding coherences
6875 if isinstance(coherence,CoherenceArray):
6876 coherence = coherence[self.coordinate]
6877 elif isinstance(coherence,MultipleCoherenceArray):
6878 coherence = coherence[self.coordinate[...,:1]]
6879 coh_axes = []
6880 for ax, coh in zip(axes.flatten(),coherence.flatten()):
6881 coh_ax = ax.twinx()
6882 coh_ax.plot(coh.abscissa,coh.ordinate,color=plt.rcParams['axes.prop_cycle'].by_key()['color'][1],**plot_kwargs)
6883 coh_ax.set_yscale('linear')
6884 coh_ax.set_ylim([-0.05,1.05])
6885 coh_axes.append(coh_ax)
6886 return axes,np.array(coh_axes).reshape(axes.shape)
6888 def delay_response(self, dt):
6889 """
6890 Adjusts the FRF phases as if the response had been shifted `dt` in time
6892 Parameters
6893 ----------
6894 dt : float
6895 Time shift to apply to the responses
6897 Returns
6898 -------
6899 shifted_transfer_function : TransferFunctionArray
6900 A copy of the transfer function with the phase shifted
6901 """
6902 shifted_transfer_function = self.copy()
6903 omegas = shifted_transfer_function.abscissa * 2 * np.pi
6904 shifted_transfer_function.ordinate *= np.exp(-1j * omegas * dt)
6905 return shifted_transfer_function
6907 @property
6908 def function_type(self):
6909 """
6910 Returns the function type of the data array
6911 """
6912 return FunctionTypes.FREQUENCY_RESPONSE_FUNCTION
6914 def apply_transformation(self, response_transformation=None, reference_transformation=None,
6915 invert_response_transformation=False, invert_reference_transformation=True):
6916 """
6917 Applies reference and response transformations to the transfer
6918 functions.
6920 Parameters
6921 ----------
6922 response_transformation : Matrix, optional
6923 The response transformation to apply to the (rows of the)
6924 transfer functions. It should be a SDynPy matrix object with
6925 the "transformed" coordinates on the rows and the "physical"
6926 coordinates on the columns. The matrix can be either 2D or 3D
6927 (for a frequency dependent transform).
6928 reference_transformation : Matrix, optional
6929 The reference transformation to apply to the (columns of the)
6930 transfer functions. It should be a SDynPy matrix object with
6931 the "transformed" coordinates on the rows and the "physical"
6932 coordinates on the columns. The matrix can be either 2D or 3D
6933 (for a frequency dependent transform).
6934 invert_response_transformation : bool, optional
6935 Whether or not to invert the response transformation when
6936 applying it to the transfer functions. The default is false,
6937 which is standard practice. The row/column ordering in the
6938 reference transformation should be flipped if this is set to
6939 true.
6940 invert_reference_transformation : bool, optional
6941 Whether or not to invert the reference transformation when
6942 applying it to the transfer functions. The default is true,
6943 which is standard practice. The row/column ordering in the
6944 reference transformation should be flipped if this is set to
6945 false.
6947 Raises
6948 ------
6949 ValueError
6950 If the physical degrees of freedom in the transformations don't
6951 match the transfer functions
6953 Returns
6954 -------
6955 transformed_transfer_function : TransferFunctionArray
6956 The transfer functions with the transformations applied.
6958 Notes
6959 -----
6960 This method can be used with just a response transformation, just a reference
6961 transformation, or both a response and reference transformation. The
6962 transformation will be set to identity if it is not supplied.
6964 References
6965 ----------
6966 .. [1] M. Van der Seijs, D. van den Bosch, D. Rixen, and D. Klerk, "An improved
6967 methodology for the virtual point transformation of measured frequency
6968 response functions in dynamic substructuring," in Proceedings of the 4th
6969 International Conference on Computational Methods in Structural Dynamics
6970 and Earthquake Engineering, Kos Island, 2013, pp. 4334-4347,
6971 doi: 10.7712/120113.4816.C1539.
6972 """
6973 if not self.validate_common_abscissa():
6974 raise ValueError('The abscissa must be consistent accross all functions in the NDDataArray')
6976 physical_response_coordinate = np.unique(self.response_coordinate)
6977 physical_reference_coordinate = np.unique(self.reference_coordinate)
6978 original_frf_ordinate = np.moveaxis(self[outer_product(physical_response_coordinate, physical_reference_coordinate)].ordinate, -1, 0)
6980 if reference_transformation is None:
6981 transformed_reference_coordinate = physical_reference_coordinate
6982 reference_transformation_matrix = np.eye(physical_reference_coordinate.shape[0])
6983 else:
6984 if invert_reference_transformation:
6985 if not np.all(np.unique(reference_transformation.column_coordinate) == physical_reference_coordinate):
6986 raise ValueError('The physical coordinates in the reference transformation do no match the transfer functions')
6987 transformed_reference_coordinate = np.unique(reference_transformation.row_coordinate)
6988 reference_transformation_matrix = np.linalg.pinv(reference_transformation[transformed_reference_coordinate, physical_reference_coordinate])
6989 elif not invert_reference_transformation:
6990 if not np.all(np.unique(reference_transformation.row_coordinate) == physical_reference_coordinate):
6991 raise ValueError('The physical coordinates in the reference transformation do no match the transfer functions')
6992 transformed_reference_coordinate = np.unique(reference_transformation.column_coordinate)
6993 reference_transformation_matrix = reference_transformation[physical_reference_coordinate, transformed_reference_coordinate]
6995 if response_transformation is None:
6996 transformed_response_coordinate = physical_response_coordinate
6997 response_transformation_matrix = np.eye(physical_response_coordinate.shape[0])
6998 else:
6999 if invert_response_transformation:
7000 if not np.all(np.unique(response_transformation.row_coordinate) == physical_response_coordinate):
7001 raise ValueError('The physical coordinates in the response transformation do no match the transfer functions')
7002 transformed_response_coordinate = np.unique(response_transformation.row_coordinate)
7003 response_transformation_matrix = np.linalg.pinv(response_transformation[transformed_response_coordinate, physical_response_coordinate])
7004 elif not invert_response_transformation:
7005 if not np.all(np.unique(response_transformation.column_coordinate) == physical_response_coordinate):
7006 raise ValueError('The physical coordinates in the response transformation do no match the transfer functions')
7007 transformed_response_coordinate = np.unique(response_transformation.row_coordinate)
7008 response_transformation_matrix = response_transformation[transformed_response_coordinate, physical_response_coordinate]
7010 transformed_frf_ordinate = response_transformation_matrix @ original_frf_ordinate @ reference_transformation_matrix
7012 return data_array(FunctionTypes.FREQUENCY_RESPONSE_FUNCTION, self.ravel().abscissa[0], np.moveaxis(transformed_frf_ordinate, 0, -1),
7013 outer_product(transformed_response_coordinate, transformed_reference_coordinate))
7015 def interpolate_by_zero_pad(self, irf_padded_length, return_irf=False,
7016 odd_num_samples = False):
7017 """
7018 Interpolates a transfer function by zero padding or truncating its
7019 impulse response
7021 Parameters
7022 ----------
7023 irf_padded_length : int
7024 Length of the final zero-padded impulse response function
7025 return_irf : bool, optional
7026 If True, the zero-padded impulse response function will be returned.
7027 If False, it will be transformed back to a transfer function prior
7028 to being returned.
7029 odd_num_samples : bool, optional
7030 If True, then it is assumed that the spectrum has been constructed
7031 from a signal with an odd number of samples. Note that this
7032 function uses the rfft function from scipy to compute the
7033 inverse fast fourier transform. The irfft function is not round-trip
7034 equivalent for odd functions, because by default it assumes an even
7035 signal length. For an odd signal length, the user must either specify
7036 odd_num_samples = True to make it round-trip equivalent.
7038 Returns
7039 -------
7040 TransferFunctionArray or ImpulseResponseFunctionArray:
7041 Transfer function array with appropriately spaced abscissa
7043 Notes
7044 -----
7045 This function will automatically set the last frequency line of the
7046 TransferFunctionArray to zero because it won't be accurate anyway.
7047 If `irf_padded_length` is less than the current function's `num_elements`,
7048 then it will be truncated instead of zero-padded.
7049 """
7050 irf = self.ifft(odd_num_samples=odd_num_samples)
7051 if irf_padded_length < irf.num_elements:
7052 irf = irf.idx_by_el[:irf_padded_length]
7053 else:
7054 irf = irf.zero_pad(irf_padded_length - irf.num_elements,left=False,right=True)
7055 if return_irf:
7056 return irf
7057 else:
7058 frf = irf.fft(norm='backward')
7059 if irf_padded_length % 2 == 0:
7060 frf.ordinate[...,-1] = 0
7061 return frf
7063 @classmethod
7064 def block_diagonal_frf(cls, component_frfs:tuple, coordinate_node_offset:int=0):
7065 """
7066 Assembles a block diagonal FRF TransferFunctionArray from the supplied
7067 FRFs.
7069 Parameters
7070 ----------
7071 component_frfs : iterable of TransferFunctionArrays
7072 A set of FRFs to be assembled into a block diagonal FRF matrix.
7073 coordinate_node_offset : int, optional
7074 An offset that is applied to the nodes in the supplied FRFs. The
7075 default is zero, meaning that an offset is not applied.
7077 Raises
7078 ------
7079 ValueError
7080 If the objects in component FRFs are not TransferFunctionArrays.
7081 ValueError
7082 If the TransferFunctionArrays do not share the same abscissa.
7084 Returns
7085 -------
7086 TransferFunctionArray
7087 The FRFs organized in block diagonal format.
7089 Notes
7090 -----
7091 The coordinate_node_offset is incremented for each system. For example,
7092 if the first set of FRFs has nodes [1,2,3,4], the second set of FRFs
7093 has nodes [3,4,5,6], and the supplied offset is 10, the node numbers in
7094 the returned block diagonal FRFs would be [11,12,13,14,23,24,25,26].
7095 """
7096 number_references = 0
7097 number_responses = 0
7098 for ii, frfs in enumerate(component_frfs):
7099 if not isinstance(frfs, TransferFunctionArray):
7100 raise ValueError('The supplied FRFs must be TransferFunctionArrays')
7101 if ii == 0:
7102 check_abscissa = frfs.ravel().abscissa[0]
7103 else:
7104 if np.all(frfs.ravel().abscissa[0] != check_abscissa):
7105 raise ValueError('The abscissa for the supplied FRFs does not match')
7106 number_responses += np.unique(frfs.response_coordinate).shape[0]
7107 number_references += np.unique(frfs.reference_coordinate).shape[0]
7109 reference_index_offset = 0
7110 response_index_offset = 0
7111 block_diagonal_frf_ord = np.zeros((number_responses, number_references, check_abscissa.shape[0]), dtype=complex)
7112 reference_coordinate_string = []
7113 response_coordinate_string = []
7114 for ii, frfs in enumerate(component_frfs):
7115 # Ensuring the shape of the FRF matrix
7116 frfs = frfs.reshape_to_matrix()
7118 # Building the block diagonal FRF matrix
7119 response_slice = slice(response_index_offset, response_index_offset+frfs.shape[0])
7120 reference_slice = slice(reference_index_offset, reference_index_offset+frfs.shape[1])
7121 block_diagonal_frf_ord[response_slice, reference_slice, :] = frfs.ordinate
7123 # Building the string array for the FRF coordinates
7124 loop_res_coord = frfs[:,0].response_coordinate.copy()
7125 loop_ref_coord = frfs[0,:].reference_coordinate.copy()
7126 if coordinate_node_offset != 0:
7127 # Apply offset the node numbers
7128 loop_res_coord.node += coordinate_node_offset * (ii+1)
7129 loop_ref_coord.node += coordinate_node_offset * (ii+1)
7130 response_coordinate_string.extend(loop_res_coord.string_array())
7131 reference_coordinate_string.extend(loop_ref_coord.string_array())
7133 # Adding the slicing offset for the block diagonal FRFs
7134 response_index_offset += frfs.shape[0]
7135 reference_index_offset += frfs.shape[1]
7137 response_coordinate = coordinate_array(string_array=response_coordinate_string)
7138 reference_coordinate = coordinate_array(string_array=reference_coordinate_string)
7139 block_diagonal_frf_coordinate = outer_product(response_coordinate, reference_coordinate)
7140 return transfer_function_array(check_abscissa, block_diagonal_frf_ord, block_diagonal_frf_coordinate)
7142 def substructure_by_constraint_matrix(self, dofs, constraint_matrix):
7143 """
7144 Performs frequency based substructuring using the supplied constraint
7145 matrix with accompanying dof list.
7147 Parameters
7148 ----------
7149 dofs : CoordinateArray
7150 Coordinates to use in the constraints.
7151 constraint_matrix : np.ndarray
7152 Constraints to apply to the frequency response functions. Should be sized
7153 [number of constraints, number of dofs].
7155 Raises
7156 ------
7157 ValueError
7158 If listed degrees of freedom are not found in the function.
7160 Returns
7161 -------
7162 constrained_frfs : TransferFunctionArray
7163 Constrained Frequency Response Functions
7165 """
7166 # Create a rectangular FRF array
7167 rect_frfs = self.reshape_to_matrix().copy()
7168 # Extract the reference and response dofs
7169 response_dofs = rect_frfs[:, 0].response_coordinate
7170 reference_dofs = rect_frfs[0, :].reference_coordinate
7171 # Now find the indices of each of the dofs
7172 reference_indices = np.searchsorted(abs(reference_dofs),
7173 abs(dofs))
7174 if np.any(abs(reference_dofs[reference_indices]) !=
7175 abs(dofs)):
7176 raise ValueError('Not all constraint degrees of freedom are found in the references')
7177 response_indices = np.searchsorted(abs(response_dofs),
7178 abs(dofs))
7179 if np.any(abs(response_dofs[response_indices]) !=
7180 abs(dofs)):
7181 raise ValueError('Not all constraint degrees of freedom are found in the responses')
7182 # Handle sign flipping
7183 flip_sign_references = reference_dofs[reference_indices].sign() * dofs.sign()
7184 flip_sign_responses = response_dofs[response_indices].sign() * dofs.sign()
7185 # Put together the constraint matrix
7186 constraint_matrix_responses = np.zeros((constraint_matrix.shape[0], response_dofs.size))
7187 constraint_matrix_references = np.zeros((constraint_matrix.shape[0], reference_dofs.size))
7188 constraint_matrix_responses[:, response_indices] = flip_sign_responses * constraint_matrix
7189 constraint_matrix_references[:,
7190 reference_indices] = flip_sign_references * constraint_matrix
7191 # Perform the constraint
7192 H = np.moveaxis(rect_frfs.ordinate, -1, 0)
7193 kernel = np.linalg.pinv(constraint_matrix_responses @ H @ constraint_matrix_references.T)
7194 H_constrained = H - H @ constraint_matrix_references.T @ kernel @ constraint_matrix_responses @ H
7195 rect_frfs.ordinate = np.moveaxis(H_constrained, 0, -1)
7196 return rect_frfs
7198 def substructure_by_coordinate(self, dof_pairs):
7199 """
7200 Performs frequency based substructuring by constraining pairs of degrees
7201 of freedom
7203 Parameters
7204 ----------
7205 dof_pairs : CoordinateArray or None
7206 Pairs of coordinates to constrain together. To constain a coordinate
7207 to ground (i.e. fix it so it cannot move), the coordinate should be
7208 paired with None. This should be size [number of constraints, 2].
7210 Returns
7211 -------
7212 TransferFunctionArray
7213 Constrained frequency response functions.
7214 """
7215 dof_list = []
7216 constraint_matrix_values = []
7217 for constraint_index, dof_pair in enumerate(dof_pairs):
7218 for sign, dof in zip([1, -1], dof_pair):
7219 if dof is None:
7220 continue
7221 try:
7222 index = dof_list.index(abs(dof))
7223 except ValueError:
7224 dof_list.append(abs(dof))
7225 index = len(dof_list) - 1
7226 flip_sign = dof.sign()
7227 constraint_matrix_values.append((constraint_index, index,
7228 flip_sign * sign))
7229 # Now create the final matrix and fill it
7230 constraint_matrix = np.zeros((len(dof_pairs),
7231 len(dof_list)))
7232 for row, column, value in constraint_matrix_values:
7233 constraint_matrix[row, column] = value
7234 # Apply Constraints
7235 dof_list = np.array(dof_list).view(CoordinateArray)
7236 return self.substructure_by_constraint_matrix(dof_list, constraint_matrix)
7238 @classmethod
7239 def from_exodus(cls, exo, reference_coordinate = None,
7240 xval = 'DispX', xvali = 'ImagDispX',
7241 yval = 'DispY', yvali = 'ImagDispY',
7242 zval = 'DispZ', zvali = 'ImagDispZ'):
7243 """Reads FRF data from a Sierra/SD ModalFRF Exodus file
7245 Parameters
7246 ----------
7247 exo : Exodus or ExodusInMemory
7248 The exodus data from which FRF data will be created.
7249 reference_coord : CoordinateArray
7250 The coordinate to be assigned as the reference coordinate,
7251 by default 0
7252 xval : str, optional
7253 The variable name representing the real part of the
7254 X value, by default 'DispX'
7255 xvali : str, optional
7256 The variable name representing the imaginary part of the
7257 X value, by default 'ImagDispX'
7258 yval : str, optional
7259 The variable name representing the real part of the
7260 Y value, by default 'DispY'
7261 yvali : str, optional
7262 The variable name representing the imaginary part of the
7263 Y value, by default 'ImagDispY'
7264 zval : str, optional
7265 The variable name representing the real part of the
7266 Z value, by default 'DispZ'
7267 zvali : str, optional
7268 The variable name representing the imaginary part of the
7269 Z value, by default 'ImagDispZ'
7271 Returns
7272 -------
7273 TransferFunctionArray
7274 FRF data from the exodus file
7275 """
7276 if isinstance(exo, Exodus):
7277 variables = [v for v in [xval, xvali, yval, yvali, zval, zvali] if v is not None]
7278 exo = exo.load_into_memory(close=False, variables=variables, timesteps=None, blocks=[])
7279 node_ids = np.arange(
7280 exo.nodes.coordinates.shape[0]) + 1 if exo.nodes.node_num_map is None else exo.nodes.node_num_map
7281 if reference_coordinate is None:
7282 reference_coordinate = coordinate_array(0,0)
7283 data = []
7284 coordinates = []
7285 for real_val, imag_val,coordinate_dir in [[xval,xvali,'X+'],
7286 [yval,yvali,'Y+'],
7287 [zval,zvali,'Z+']]:
7288 if real_val is None or imag_val is None:
7289 continue
7290 real_data = [var for var in exo.nodal_vars if var.name.lower() == real_val.lower(
7291 )][0].data
7292 imag_data = [var for var in exo.nodal_vars if var.name.lower() == imag_val.lower(
7293 )][0].data
7294 data.append(real_data+1j*imag_data)
7295 coordinates.append(coordinate_array(node_ids,coordinate_dir))
7297 data = np.array(data).transpose(0,2,1)
7298 coordinates = np.array(coordinates).view(CoordinateArray)
7299 reference_coordinates = coordinates.copy()
7300 reference_coordinates[...] = reference_coordinate
7301 coordinates = np.concatenate((coordinates[...,np.newaxis],reference_coordinates[...,np.newaxis]),axis=-1)
7303 return transfer_function_array(exo.time,data,coordinates)
7306def transfer_function_array(abscissa,ordinate,coordinate,
7307 comment1='',comment2='',
7308 comment3='',comment4='',comment5=''):
7309 """
7310 Helper function to create a TransferFunctionArray object.
7312 All input arguments to this function are allowed to broadcast to create the
7313 final data in the TransferFunctionArray object.
7315 Parameters
7316 ----------
7317 abscissa : np.ndarray
7318 Numpy array specifying the abscissa of the function
7319 ordinate : np.ndarray
7320 Numpy array specifying the ordinate of the function
7321 coordinate : CoordinateArray
7322 Coordinate for each data in the data array
7323 comment1 : np.ndarray, optional
7324 Comment used to describe the data in the data array. The default is ''.
7325 comment2 : np.ndarray, optional
7326 Comment used to describe the data in the data array. The default is ''.
7327 comment3 : np.ndarray, optional
7328 Comment used to describe the data in the data array. The default is ''.
7329 comment4 : np.ndarray, optional
7330 Comment used to describe the data in the data array. The default is ''.
7331 comment5 : np.ndarray, optional
7332 Comment used to describe the data in the data array. The default is ''.
7334 Returns
7335 -------
7336 obj : TransferFunctionArray
7337 The constructed TransferFunctionArray object
7338 """
7339 return data_array(FunctionTypes.FREQUENCY_RESPONSE_FUNCTION,abscissa,ordinate,
7340 coordinate, comment1,comment2,comment3,comment4,comment5)
7343class ImpulseResponseFunctionArray(NDDataArray):
7344 """Data array used to store impulse response functions"""
7345 def __new__(subtype, shape, nelements, buffer=None, offset=0,
7346 strides=None, order=None):
7347 obj = super().__new__(subtype, shape, nelements, 2, 'float64', buffer, offset, strides, order)
7348 return obj
7350 @property
7351 def function_type(self):
7352 """
7353 Returns the function type of the data array
7354 """
7355 return FunctionTypes.IMPULSE_RESPONSE_FUNCTION
7357 def fft(self, norm='backward', **scipy_rfft_kwargs):
7358 """
7359 Converts the impulse response function to a frequency response function
7360 using the fft function.
7362 Paramters
7363 ---------
7364 norm : str, optional
7365 The type of normalization applied to the fft computation.
7366 scipy_rfft_kwargs :
7367 Additional keywords that will be passed to SciPy's rfft function.
7369 Returns
7370 -------
7371 TransferFunctionArray
7372 The transfer function array computed from the impusle response function
7373 array.
7374 """
7375 # Some initial organization
7376 irfs = self.reshape_to_matrix()
7377 irf_ordinate = np.moveaxis(irfs.ordinate, -1, 0)
7379 # Getting sampling parameters for the fft
7380 number_samples = irf_ordinate.shape[0]
7381 dt = irfs[0, 0].abscissa[1] - irfs[0, 0].abscissa[0]
7383 # Doing the fft
7384 frf_ordinate = scipyfft.rfft(irf_ordinate, axis=0, norm=norm, **scipy_rfft_kwargs)
7385 freq_vector = scipyfft.rfftfreq(number_samples, dt)
7387 # Broadcasting the frequency vector to the correct size
7388 abscissa = np.broadcast_to(freq_vector[..., np.newaxis, np.newaxis], frf_ordinate.shape)
7390 return data_array(FunctionTypes.FREQUENCY_RESPONSE_FUNCTION, np.moveaxis(abscissa, 0, -1),
7391 np.moveaxis(frf_ordinate, 0, -1), irfs.coordinate)
7393 def find_end_of_ringdown(self):
7394 """
7395 Finds the end of the ringdown in a impulse response function (IRF).
7397 It does this by smoothing the IRF via a moving average filter, then finding
7398 the index of the minimum of the smoothed IRF (for each response/reference
7399 pair). The "end of ringdown" is defined as the median of the possible
7400 indices.
7402 Returns
7403 -------
7404 end_of_ringdow : int
7405 Index that represents the end of the ringdown for the supplied IRFs.
7406 """
7407 irf_ordinate = np.moveaxis(self.ordinate, -1, 0)
7409 # Start off by performing a moving average on the IRF to make
7410 # it easier to find the end of the ring down
7411 moving_average_kernel_length = int(irf_ordinate.shape[0]/5)
7412 moving_average_kernel = np.broadcast_to(np.ones(moving_average_kernel_length)[..., np.newaxis, np.newaxis],
7413 (moving_average_kernel_length, irf_ordinate.shape[1], irf_ordinate.shape[2]))
7414 irf_moving_average = convolve(np.absolute(irf_ordinate), moving_average_kernel, mode='same')
7416 # Find where the smoothed version of the IRF is at a minimum and
7417 # then using the median index (by channel) as the window_parameter
7418 min_index = np.argmin(irf_moving_average, axis=0)
7419 end_of_ringdown = int(np.median(min_index))
7421 return end_of_ringdown
7423 def enforce_causality(self, method='exponential_taper',
7424 window_parameter=None,
7425 end_of_ringdown=None):
7426 """
7427 Enforces causality on the impulse response function via a cutoff
7428 of some sort.
7430 Parameters
7431 ----------
7432 method : str
7433 The window type that is applied to the data to enforce causality.
7434 Note that these options are not necessarily traditional windows
7435 (used for data processing). The current options are:
7436 - exponential_taper (default) - this applies a exponential taper
7437 to the end of a boxcar window on the IRF.
7438 - boxcar - this applies a boxcar (uniform) window to the IRF
7439 with the cuttoff at a specified sample.
7440 - exponential - this applies an exponential window to the IRF
7441 with the 40 dB down point (of the window) at a specified sample.
7442 Care should be taken when using this window type, since it can
7443 lead to erratic behavior.
7444 window_parameter : int, optional
7445 This is a parameter that defines the window for the causality
7446 enforcement. Methods exist to define this parameter automatically
7447 if it isn't provided. The behaviors for the options are:
7448 - boxcar - the window_paramter is the sample after which the
7449 IRF is set to zero. It is the same as the end_of_ringdown
7450 parameter for this window type.
7451 - exponential - the window_parameter is where the 40 dB down
7452 point is for the window. It is the same as the end_of_ringdown
7453 parameter for this window type.
7454 - exponential_taper - the window_parameter is where the end point
7455 of the window (where the amplitude is 0.001), as defined by the
7456 number of samples after the uniform section of the window.
7457 end_of_ringdown : int, optional
7458 This is a parameter that defines the end of the uniform section of
7459 the exponetional_taper window. It is not used for either the boxcar
7460 or exponential window. Methods exist to define this parameter
7461 automatically if it isn't provided.
7463 Returns
7464 -------
7465 ImpulseResponseFunctionArray
7466 The IRF with causality enforced.
7468 Notes
7469 -----
7470 Although optional, it is best practice for the user to supply a parameter
7471 for the end_of_ringdown variable if the "exponential_taper" method is
7472 being used or a window_parameter if the "exponential" or "boxcar" methods
7473 are being used. The code will attempt to find the end of the ring-down in
7474 the IRF and use use that as the end_of_ringdown parameter for the
7475 "exponential_taper" window or the window_parameter for the exponential and
7476 boxcar windows.
7478 It is not suggested that the user provide a window_paramter if the
7479 "exponential_taper" method is being used, since the default is likely the
7480 most logical choice.
7482 References
7483 ----------
7484 .. [1] Zvonkin, M. (2015). Methods for checking and enforcing physical quality of linear electrical network models
7485 [Masters Theses, Missouri University of Science and Technology], Missouri S&T Scholars' Mine, https://scholarsmine.mst.edu/masters_theses/7490/
7486 """
7487 # Organizing the IRFs and pulling the ordinate out
7488 irfs = self.reshape_to_matrix()
7489 irf_ord = np.moveaxis(irfs.ordinate, -1, 0)
7491 if method == 'exponential_taper' and end_of_ringdown is None:
7492 end_of_ringdown = self.find_end_of_ringdown()
7494 if method == 'boxcar' and window_parameter is not None and window_parameter >= irf_ord.shape[0]:
7495 raise ValueError('window parameter is greater than the IRF block size and creates an illogical window for the data')
7497 if method in ['exponential', 'boxcar'] and window_parameter is None:
7498 window_parameter = self.find_end_of_ringdown()
7500 # Generating the desired window, based on the seleted method
7501 if method == 'exponential_taper':
7502 window = np.ones(irf_ord.shape[0])
7503 window[end_of_ringdown:] = np.zeros(irf_ord.shape[0] - end_of_ringdown)
7504 window_length = irf_ord.shape[0] - end_of_ringdown
7505 if window_parameter is None:
7506 window_parameter = window_length
7507 window_tau = -(window_parameter-1)/np.log(0.001)
7508 window[end_of_ringdown+np.arange(window_length)] = exponential(window_length, center=0, tau=window_tau, sym=False)
7509 elif method == 'exponential':
7510 end_of_ringdown = window_parameter
7511 window_tau = window_parameter*8.69/20
7512 window = exponential(irf_ord.shape[0], center=0, tau=window_tau, sym=False)
7513 elif method == 'boxcar':
7514 end_of_ringdown = window_parameter
7515 window = np.ones(irf_ord.shape[0])
7516 window[window_parameter:] = 0
7518 # Setting up the time reversal window so the non-causal portion of the IRF can be added back to the IRFs
7519 time_reversal_window = np.ones(irf_ord.shape[0]) - window
7520 # time_reversal_window[:window_parameter] = 0, might need this if using an exponential window
7522 method_statement_start = 'Causality is being enforced using '
7523 method_statement_middle = ' method with a end_of_ringdown of '
7524 print(method_statement_start+method+method_statement_middle+str(end_of_ringdown))
7526 # Applying the window to the data for the causality enforcement
7527 irf_causal_ord = irf_ord * window[..., np.newaxis, np.newaxis]
7528 irf_noncausal_ord = irf_ord * time_reversal_window[..., np.newaxis, np.newaxis]
7530 # Generating the new impulse response function array
7531 irfs_causal = irfs
7532 irfs_causal.ordinate = np.moveaxis(irf_causal_ord+np.flip(irf_noncausal_ord, 0), 0, -1)
7534 return irfs_causal
7536def impulse_response_function_array(abscissa,ordinate,coordinate,
7537 comment1='',comment2='',
7538 comment3='',comment4='',comment5=''):
7539 """
7540 Helper function to create a ImpulseResponseFunctionArray object.
7542 All input arguments to this function are allowed to broadcast to create the
7543 final data in the ImpulseResponseFunctionArray object.
7545 Parameters
7546 ----------
7547 abscissa : np.ndarray
7548 Numpy array specifying the abscissa of the function
7549 ordinate : np.ndarray
7550 Numpy array specifying the ordinate of the function
7551 coordinate : CoordinateArray
7552 Coordinate for each data in the data array
7553 comment1 : np.ndarray, optional
7554 Comment used to describe the data in the data array. The default is ''.
7555 comment2 : np.ndarray, optional
7556 Comment used to describe the data in the data array. The default is ''.
7557 comment3 : np.ndarray, optional
7558 Comment used to describe the data in the data array. The default is ''.
7559 comment4 : np.ndarray, optional
7560 Comment used to describe the data in the data array. The default is ''.
7561 comment5 : np.ndarray, optional
7562 Comment used to describe the data in the data array. The default is ''.
7564 Returns
7565 -------
7566 obj : ImpulseResponseFunctionArray
7567 The constructed ImpulseResponseFunctionArray object
7568 """
7569 return data_array(FunctionTypes.IMPULSE_RESPONSE_FUNCTION,abscissa,ordinate,
7570 coordinate, comment1,comment2,comment3,comment4,comment5)
7572class TransmissibilityArray(NDDataArray):
7573 """Data array used to store transmissibility data"""
7574 def __new__(subtype, shape, nelements, buffer=None, offset=0,
7575 strides=None, order=None):
7576 obj = super().__new__(subtype, shape, nelements, 2, 'complex128', buffer, offset, strides, order)
7577 return obj
7579 @property
7580 def function_type(self):
7581 """
7582 Returns the function type of the data array
7583 """
7584 return FunctionTypes.TRANSMISIBILITY
7587def transmissibility_array(abscissa,ordinate,coordinate,
7588 comment1='',comment2='',
7589 comment3='',comment4='',comment5=''):
7590 """
7591 Helper function to create a TransmissibilityArray object.
7593 All input arguments to this function are allowed to broadcast to create the
7594 final data in the TransmissibilityArray object.
7596 Parameters
7597 ----------
7598 abscissa : np.ndarray
7599 Numpy array specifying the abscissa of the function
7600 ordinate : np.ndarray
7601 Numpy array specifying the ordinate of the function
7602 coordinate : CoordinateArray
7603 Coordinate for each data in the data array
7604 comment1 : np.ndarray, optional
7605 Comment used to describe the data in the data array. The default is ''.
7606 comment2 : np.ndarray, optional
7607 Comment used to describe the data in the data array. The default is ''.
7608 comment3 : np.ndarray, optional
7609 Comment used to describe the data in the data array. The default is ''.
7610 comment4 : np.ndarray, optional
7611 Comment used to describe the data in the data array. The default is ''.
7612 comment5 : np.ndarray, optional
7613 Comment used to describe the data in the data array. The default is ''.
7615 Returns
7616 -------
7617 obj : TransmissibilityArray
7618 The constructed TransmissibilityArray object
7619 """
7620 return data_array(FunctionTypes.TRANSMISIBILITY,abscissa,ordinate,
7621 coordinate, comment1,comment2,comment3,comment4,comment5)
7624class CoherenceArray(NDDataArray):
7625 """Data array used to store coherence data"""
7626 def __new__(subtype, shape, nelements, buffer=None, offset=0,
7627 strides=None, order=None):
7628 obj = super().__new__(subtype, shape, nelements, 2, 'float64', buffer, offset, strides, order)
7629 return obj
7631 @property
7632 def function_type(self):
7633 """
7634 Returns the function type of the data array
7635 """
7636 return FunctionTypes.COHERENCE
7638 @staticmethod
7639 def from_time_data(response_data: TimeHistoryArray,
7640 samples_per_average: int = None,
7641 overlap: float = 0.0,
7642 window=np.array((1.0,)),
7643 reference_data: TimeHistoryArray = None):
7644 """
7645 Computes coherence from reference and response time histories
7647 Parameters
7648 ----------
7649 response_data : TimeHistoryArray
7650 Time data to be used as responses
7651 samples_per_average : int, optional
7652 Number of samples used to split up the signals into averages. The
7653 default is None, meaning the data is treated as a single measurement
7654 frame.
7655 overlap : float, optional
7656 The overlap as a fraction of the frame (e.g. 0.5 specifies 50% overlap).
7657 The default is 0.0, meaning no overlap is used.
7658 window : np.ndarray or str, optional
7659 A 1D ndarray with length samples_per_average that specifies the
7660 coefficients of the window. A Hann window is applied if not specified.
7661 If a string is specified, then the window will be obtained from scipy.
7662 reference_data : TimeHistoryArray
7663 Time data to be used as reference. If not specified, the response
7664 data will be used as references, resulting in a square coherence matrix.
7666 Raises
7667 ------
7668 ValueError
7669 Raised if reference and response functions do not have consistent
7670 abscissa
7672 Returns
7673 -------
7674 PowerSpectralDensityArray
7675 A PSD array computed from the specified reference and
7676 response signals.
7678 """
7679 if reference_data is None:
7680 reference_data = response_data
7681 ref_data = reference_data.flatten()
7682 res_data = response_data.flatten()
7683 ref_ord = ref_data.ordinate
7684 res_ord = res_data.ordinate
7685 if ((not np.allclose(ref_data[0].abscissa,
7686 res_data[0].abscissa))
7687 or (not np.allclose(ref_data.abscissa_spacing,res_data.abscissa_spacing))):
7688 raise ValueError('Reference and Response Data should have identical abscissa!')
7689 dt = res_data.abscissa_spacing
7690 df, cpsd = sp_cpsd(res_ord, 1/dt, samples_per_average, overlap,
7691 window, reference_signals = ref_ord)
7692 df, res_asds = sp_cpsd(res_ord, 1/dt, samples_per_average, overlap,
7693 window, only_asds = True)
7694 df, ref_asds = sp_cpsd(ref_ord, 1/dt, samples_per_average, overlap,
7695 window, only_asds = True)
7696 num = np.abs(cpsd)**2
7697 den = res_asds[...,np.newaxis]*ref_asds[:,np.newaxis,:]
7698 den[den == 0.0] = 1 # Set to 1 if denominator is zero
7699 coh = np.real(num/den)
7700 freq = np.arange(cpsd.shape[0])*df
7701 # Now construct the transfer function array
7702 coordinate = outer_product(res_data.coordinate.flatten(),
7703 ref_data.coordinate.flatten())
7704 return data_array(FunctionTypes.COHERENCE,
7705 freq, np.moveaxis(coh, 0, -1), coordinate)
7708def coherence_array(abscissa,ordinate,coordinate,
7709 comment1='',comment2='',
7710 comment3='',comment4='',comment5=''):
7711 """
7712 Helper function to create a CoherenceArray object.
7714 All input arguments to this function are allowed to broadcast to create the
7715 final data in the CoherenceArray object.
7717 Parameters
7718 ----------
7719 abscissa : np.ndarray
7720 Numpy array specifying the abscissa of the function
7721 ordinate : np.ndarray
7722 Numpy array specifying the ordinate of the function
7723 coordinate : CoordinateArray
7724 Coordinate for each data in the data array
7725 comment1 : np.ndarray, optional
7726 Comment used to describe the data in the data array. The default is ''.
7727 comment2 : np.ndarray, optional
7728 Comment used to describe the data in the data array. The default is ''.
7729 comment3 : np.ndarray, optional
7730 Comment used to describe the data in the data array. The default is ''.
7731 comment4 : np.ndarray, optional
7732 Comment used to describe the data in the data array. The default is ''.
7733 comment5 : np.ndarray, optional
7734 Comment used to describe the data in the data array. The default is ''.
7736 Returns
7737 -------
7738 obj : CoherenceArray
7739 The constructed CoherenceArray object
7740 """
7741 return data_array(FunctionTypes.COHERENCE,abscissa,ordinate,
7742 coordinate, comment1,comment2,comment3,comment4,comment5)
7745class MultipleCoherenceArray(NDDataArray):
7746 """Data array used to store coherence data"""
7747 def __new__(subtype, shape, nelements, buffer=None, offset=0,
7748 strides=None, order=None):
7749 obj = super().__new__(subtype, shape, nelements, 1, 'float64', buffer, offset, strides, order)
7750 return obj
7752 @property
7753 def function_type(self):
7754 """
7755 Returns the function type of the data array
7756 """
7757 return FunctionTypes.MULTIPLE_COHERENCE
7759 @staticmethod
7760 def from_time_data(response_data: TimeHistoryArray,
7761 samples_per_average: int = None,
7762 overlap: float = 0.0,
7763 window=np.array((1.0,)),
7764 reference_data: TimeHistoryArray = None):
7765 """
7766 Computes coherence from reference and response time histories
7768 Parameters
7769 ----------
7770 response_data : TimeHistoryArray
7771 Time data to be used as responses
7772 samples_per_average : int, optional
7773 Number of samples used to split up the signals into averages. The
7774 default is None, meaning the data is treated as a single measurement
7775 frame.
7776 overlap : float, optional
7777 The overlap as a fraction of the frame (e.g. 0.5 specifies 50% overlap).
7778 The default is 0.0, meaning no overlap is used.
7779 window : np.ndarray or str, optional
7780 A 1D ndarray with length samples_per_average that specifies the
7781 coefficients of the window. A Hann window is applied if not specified.
7782 If a string is specified, then the window will be obtained from scipy.
7783 reference_data : TimeHistoryArray
7784 Time data to be used as reference. If not specified, the response
7785 data will be used as references, resulting in a square coherence matrix.
7787 Raises
7788 ------
7789 ValueError
7790 Raised if reference and response functions do not have consistent
7791 abscissa
7793 Returns
7794 -------
7795 PowerSpectralDensityArray
7796 A PSD array computed from the specified reference and
7797 response signals.
7799 """
7800 if reference_data is None:
7801 reference_data = response_data
7802 ref_data = reference_data.flatten()
7803 res_data = response_data.flatten()
7804 ref_ord = ref_data.ordinate
7805 res_ord = res_data.ordinate
7806 if ((not np.allclose(ref_data[0].abscissa,
7807 res_data[0].abscissa))
7808 or (not np.allclose(ref_data.abscissa_spacing,res_data.abscissa_spacing))):
7809 raise ValueError('Reference and Response Data should have identical abscissa!')
7810 dt = res_data.abscissa_spacing
7811 df, cross_cpsd = sp_cpsd(res_ord, 1/dt, samples_per_average, overlap,
7812 window, reference_signals = ref_ord)
7813 df, res_apsd = sp_cpsd(res_ord, 1/dt, samples_per_average, overlap,
7814 window,only_asds=True)
7815 df, ref_cpsd = sp_cpsd(ref_ord, 1/dt, samples_per_average, overlap,
7816 window)
7818 num = np.einsum('fij,fji->fi', cross_cpsd, np.linalg.solve(ref_cpsd,np.moveaxis(cross_cpsd.conj(),-2,-1)))
7819 den = res_apsd
7820 den[den == 0.0] = 1 # Set to 1 if denominator is zero
7821 mcoh = np.real(num/den)
7822 freq = np.arange(num.shape[0])*df
7823 return data_array(FunctionTypes.MULTIPLE_COHERENCE,
7824 freq, np.moveaxis(mcoh, 0, -1), res_data.coordinate)
7826def multiple_coherence_array(abscissa,ordinate,coordinate,
7827 comment1='',comment2='',
7828 comment3='',comment4='',comment5=''):
7829 """
7830 Helper function to create a MultipleCoherenceArray object.
7832 All input arguments to this function are allowed to broadcast to create the
7833 final data in the MultipleCoherenceArray object.
7835 Parameters
7836 ----------
7837 abscissa : np.ndarray
7838 Numpy array specifying the abscissa of the function
7839 ordinate : np.ndarray
7840 Numpy array specifying the ordinate of the function
7841 coordinate : CoordinateArray
7842 Coordinate for each data in the data array
7843 comment1 : np.ndarray, optional
7844 Comment used to describe the data in the data array. The default is ''.
7845 comment2 : np.ndarray, optional
7846 Comment used to describe the data in the data array. The default is ''.
7847 comment3 : np.ndarray, optional
7848 Comment used to describe the data in the data array. The default is ''.
7849 comment4 : np.ndarray, optional
7850 Comment used to describe the data in the data array. The default is ''.
7851 comment5 : np.ndarray, optional
7852 Comment used to describe the data in the data array. The default is ''.
7854 Returns
7855 -------
7856 obj : MultipleCoherenceArray
7857 The constructed MultipleCoherenceArray object
7858 """
7859 return data_array(FunctionTypes.MULTIPLE_COHERENCE,abscissa,ordinate,
7860 coordinate, comment1,comment2,comment3,comment4,comment5)
7863class CorrelationArray(NDDataArray):
7864 """Data array used to store correlation data"""
7865 @property
7866 def function_type(self):
7867 """
7868 Returns the function type of the data array
7869 """
7870 return FunctionTypes.CROSSCORRELATION
7872def correlation_array(abscissa,ordinate,coordinate,
7873 comment1='',comment2='',
7874 comment3='',comment4='',comment5=''):
7875 """
7876 Helper function to create a CorrelationArray object.
7878 All input arguments to this function are allowed to broadcast to create the
7879 final data in the CorrelationArray object.
7881 Parameters
7882 ----------
7883 abscissa : np.ndarray
7884 Numpy array specifying the abscissa of the function
7885 ordinate : np.ndarray
7886 Numpy array specifying the ordinate of the function
7887 coordinate : CoordinateArray
7888 Coordinate for each data in the data array
7889 comment1 : np.ndarray, optional
7890 Comment used to describe the data in the data array. The default is ''.
7891 comment2 : np.ndarray, optional
7892 Comment used to describe the data in the data array. The default is ''.
7893 comment3 : np.ndarray, optional
7894 Comment used to describe the data in the data array. The default is ''.
7895 comment4 : np.ndarray, optional
7896 Comment used to describe the data in the data array. The default is ''.
7897 comment5 : np.ndarray, optional
7898 Comment used to describe the data in the data array. The default is ''.
7900 Returns
7901 -------
7902 obj : CorrelationArray
7903 The constructed CorrelationArray object
7904 """
7905 return data_array(FunctionTypes.CROSSCORRELATION,abscissa,ordinate,
7906 coordinate, comment1,comment2,comment3,comment4,comment5)
7909class ModeIndicatorFunctionArray(NDDataArray):
7910 """Mode indicator function (CMIF, NMIF, or NMIF)"""
7911 def __new__(subtype, shape, nelements, buffer=None, offset=0,
7912 strides=None, order=None):
7913 obj = super().__new__(subtype, shape, nelements, 1, 'float64', buffer, offset, strides, order)
7914 return obj
7916 @property
7917 def function_type(self):
7918 """
7919 Returns the function type of the data array
7920 """
7921 return FunctionTypes.MODE_INDICATOR_FUNCTION
7923def mode_indicator_function_array(abscissa,ordinate,coordinate,
7924 comment1='',comment2='',
7925 comment3='',comment4='',comment5=''):
7926 """
7927 Helper function to create a ModeIndicatorFunctionArray object.
7929 All input arguments to this function are allowed to broadcast to create the
7930 final data in the ModeIndicatorFunctionArray object.
7932 Parameters
7933 ----------
7934 abscissa : np.ndarray
7935 Numpy array specifying the abscissa of the function
7936 ordinate : np.ndarray
7937 Numpy array specifying the ordinate of the function
7938 coordinate : CoordinateArray
7939 Coordinate for each data in the data array
7940 comment1 : np.ndarray, optional
7941 Comment used to describe the data in the data array. The default is ''.
7942 comment2 : np.ndarray, optional
7943 Comment used to describe the data in the data array. The default is ''.
7944 comment3 : np.ndarray, optional
7945 Comment used to describe the data in the data array. The default is ''.
7946 comment4 : np.ndarray, optional
7947 Comment used to describe the data in the data array. The default is ''.
7948 comment5 : np.ndarray, optional
7949 Comment used to describe the data in the data array. The default is ''.
7951 Returns
7952 -------
7953 obj : ModeIndicatorFunctionArray
7954 The constructed ModeIndicatorFunctionArray object
7955 """
7956 return data_array(FunctionTypes.MODE_INDICATOR_FUNCTION,abscissa,ordinate,
7957 coordinate, comment1,comment2,comment3,comment4,comment5)
7959class ShockResponseSpectrumArray(NDDataArray):
7961 _srs_type_map = {'ppaa': 1,
7962 'pnaa': 2,
7963 'pmaa': 3,
7964 'rpaa': 4,
7965 'rnaa': 5,
7966 'rmaa': 6,
7967 'mpaa': 7,
7968 'mnaa': 8,
7969 'mmaa': 9,
7970 'alaa': 10,
7971 'pprd': -1,
7972 'pnrd': -2,
7973 'pmrd': -3,
7974 'rprd': -4,
7975 'rnrd': -5,
7976 'rmrd': -6,
7977 'mprd': -7,
7978 'mnrd': -8,
7979 'mmrd': -9,
7980 'alrd': -10}
7982 """Shock Response Spectrum (SRS)"""
7983 def __new__(subtype, shape, nelements, buffer=None, offset=0,
7984 strides=None, order=None):
7985 obj = super().__new__(subtype, shape, nelements, 1, 'float64', buffer, offset, strides, order)
7986 return obj
7988 @property
7989 def function_type(self):
7990 """
7991 Returns the function type of the data array
7992 """
7993 return FunctionTypes.SHOCK_RESPONSE_SPECTRUM
7995 def sum_decayed_sines(self, sample_rate, block_size,
7996 sine_frequencies=None, sine_tone_range=None, sine_tone_per_octave=None,
7997 sine_amplitudes=None, sine_decays=None, sine_delays=None,
7998 srs_damping=0.03, srs_type="MMAA",
7999 compensation_frequency=None, compensation_decay=0.95,
8000 # Paramters for the iteration
8001 number_of_iterations=3, convergence=0.8,
8002 error_tolerance=0.05,
8003 tau=None, num_time_constants=None, decay_resolution=None,
8004 scale_factor=1.02,
8005 acceleration_factor=1.0,
8006 plot_results=False, srs_frequencies=None,
8007 return_velocity=False, return_displacement=False,
8008 return_srs=False, return_sine_table=False,
8009 ignore_compensation_pulse = False,
8010 verbose=False):
8011 """Generate a Sum of Decayed Sines signal given an SRS.
8013 Note that there are many approaches to do this, with many optional arguments
8014 so please read the documentation carefully to understand which arguments
8015 must be passed to the function.
8017 Parameters
8018 ----------
8019 sample_rate : float
8020 The sample rate of the generated signal.
8021 block_size : int
8022 The number of samples in the generated signal.
8023 sine_frequencies : np.ndarray, optional
8024 The frequencies of the sine tones. If this argument is not specified
8025 and the `sine_tone_range` argument is not specified, then the
8026 `sine_tone_range` will be set to the maximum and minimum abscissa
8027 value for this `ShockResponseSpectrumArray`.
8028 sine_tone_range : np.ndarray, optional
8029 A length-2 array containing the minimum and maximum sine tone to
8030 generate. If this argument is not specified
8031 and the `sine_frequencies` argument is not specified, then the
8032 `sine_tone_range` will be set to the maximum and minimum abscissa
8033 value for this `ShockResponseSpectrumArray`.
8034 sine_tone_per_octave : int, optional
8035 The number of sine tones per octave. If not specified along with
8036 `sine_tone_range`, then a default value of 4 will be used if the
8037 `srs_damping` is >= 0.05. Otherwise, the formula of
8038 `sine_tone_per_octave = 9 - srs_damping*100` will be used.
8039 sine_amplitudes : np.ndarray, optional
8040 The initial amplitude of the sine tones used in the optimization. If
8041 not specified, they will be set to the value of the SRS at each frequency
8042 divided by the quality factor of the SRS.
8043 sine_decays : np.ndarray, optional
8044 An array of decay value time constants (often represented by variable
8045 tau). Tau is the time for the amplitude of motion to decay 63% defined
8046 by the equation `1/(2*np.pi*freq*zeta)` where `freq` is the frequency
8047 of the sine tone and `zeta` is the fraction of critical damping.
8048 If not specified, then either the `tau` or `num_time_constants`
8049 arguments must be specified instead.
8050 sine_delays : np.ndarray, optional
8051 An array of delay values for the sine components. If not specified,
8052 all tones will have zero delay.
8053 srs_damping : float, optional
8054 Fraction of critical damping to use in the SRS calculation (e.g. you
8055 should specify 0.03 to represent 3%, not 3). If not defined, a
8056 default of 0.03 will be used.
8057 srs_type : int or str
8058 The type of spectrum desired: This can be an integer or a string.
8059 If `srs_type` is an integer:
8060 if `srs_type` > 0 (pos) then the SRS will be a base
8061 acceleration-absolute acceleration model
8062 If `srs_type` < 0 (neg) then the SRS will be a base acceleration-relative
8063 displacement model (expressed in equivalent static acceleration units).
8064 If abs(`srs_type`) is:
8065 1--positive primary, 2--negative primary, 3--absolute maximum primary
8066 4--positive residual, 5--negative residual, 6--absolute maximum residual
8067 7--largest of 1&4, maximum positive, 8--largest of 2&5, maximum negative
8068 9 -- maximax, the largest absolute value of 1-8
8069 10 -- returns a matrix s(9,length(fn)) with all the types 1-9.
8070 compensation_frequency : float
8071 The frequency of the compensation pulse. If not specified, it will be
8072 set to 1/3 of the lowest sine tone
8073 compensation_decay : float
8074 The decay value for the compensation pulse. If not specified, it will
8075 be set to 0.95.
8076 number_of_iterations : int, optional
8077 The number of iterations to perform. At least two iterations should be
8078 performed. 3 iterations is preferred, and will be used if this argument
8079 is not specified.
8080 convergence : float, optional
8081 The fraction of the error corrected each iteration. The default is 0.8.
8082 error_tolerance : float, optional
8083 Allowable relative error in the SRS. The default is 0.05.
8084 tau : float, optional
8085 If a floating point number is passed, then this will be used for the
8086 `sine_decay` values. Alternatively, a dictionary can be passed with
8087 the keys containing a length-2 tuple specifying the minimum and maximum
8088 frequency range, and the value specifying the value of `tau` within that
8089 frequency range. If this latter approach is used, all `sine_frequencies`
8090 must be contained within a frequency range. If this argument is not
8091 specified, then either `sine_decays` or `num_time_constants` must be
8092 specified instead.
8093 num_time_constants : int, optional
8094 If an integer is passed, then this will be used to set the `sine_decay`
8095 values by ensuring the specified number of time constants occur in the
8096 `block_size`. Alternatively, a dictionary can be passed with the keys
8097 containing a length-2 tuple specifying the minimum and maximum
8098 frequency range, and the value specifying the value of
8099 `num_time_constants` over that frequency range. If this latter approach
8100 is used, all `sine_frequencies` must be contained within a frequency
8101 range. If this argument is not specified, then either `sine_decays` or
8102 `tau` must be specified instead.
8103 decay_resolution : float, optional
8104 A scalar identifying the resolution of the fractional decay rate
8105 (often known by the variable `zeta`). The decay parameters will be
8106 rounded to this value. The default is to not round.
8107 scale_factor : float, optional
8108 A scaling applied to the sine tone amplitudes so the achieved SRS better
8109 fits the specified SRS, rather than just touching it. The default is 1.02.
8110 acceleration_factor : float, optional
8111 Optional scale factor to convert acceleration into velocity and
8112 displacement. For example, if sine amplitudes are in G and displacement
8113 is desired in inches, the acceleration factor should be set to 386.089.
8114 If sine amplitudes are in G and displacement is desired in meters, the
8115 acceleration factor should be set to 9.80665. The default is 1, which
8116 assumes consistent units (e.g. acceleration in m/s^2, velocity in m/s,
8117 displacement in m).
8118 plot_results : bool, optional
8119 If True, a figure will be plotted showing the acceleration, velocity,
8120 and displacement signals, as well as the desired and achieved SRS.
8121 srs_frequencies : np.ndarray, optional
8122 If specified, these frequencies will be used to compute the SRS that
8123 will be plotted when the `plot_results` value is `True`.
8124 return_velocity : bool, optional
8125 If specified, a velocity signal will also be returned. Default is
8126 False
8127 return_displacement : bool, optional
8128 If True, a displacement signal will also be returned. Default is
8129 False
8130 return_srs : bool, optional
8131 If True, the SRS of the generated signal will also be returned
8132 return_sine_table : bool, optional
8133 If True, a sine table will also be returned
8134 ignore_compensation_pulse : bool, optional
8135 If True, the compensation pulse will not be used. Default is False.
8136 verbose : True, optional
8137 If True, additional diagnostics will be printed to the console.
8139 Returns
8140 -------
8141 acceleration : TimeHistoryArray
8142 A TimeHistoryArray object containing an acceleration response that
8143 satisfies the SRS
8144 velocity : TimeHistoryArray
8145 A TimeHistoryArray object containing the velocity corresponding to
8146 `acceleration`. Only returned if `return_velocity` is True.
8147 displacement : TimeHistoryArray
8148 A TimeHistoryArray object containing the displacement corresponding
8149 to `acceleration`. Only returned if `return_displacement` is True.
8150 srs : TimeHistoryArray
8151 A `ShockResponseSpectrumArray` containing the SRS of `acceleration`.
8152 This can be used to check against the original signal to identify
8153 how good the match is. Only returned if `return_srs` is True.
8154 sine_table : DecayedSineTable
8155 A `DecayedSineTable` object containing the frequency, amplitude,
8156 delay, and decay parameters that are used to generate `acceleration`.
8157 """
8158 try:
8159 if isinstance(srs_type, str):
8160 srs_type = ShockResponseSpectrumArray._srs_type_map[srs_type.lower()]
8161 except KeyError:
8162 raise ValueError('Invalid `srs_type` specified, should be one of {:} (case insensitive)'.format(
8163 [k for k in ShockResponseSpectrumArray._srs_type_map]))
8165 acceleration = np.empty(self.shape+(block_size,))
8166 if return_displacement:
8167 displacement = np.empty(self.shape+(block_size,))
8168 if return_velocity:
8169 velocity = np.empty(self.shape+(block_size,))
8170 if return_srs:
8171 srs = None
8172 if return_sine_table:
8173 sine_table = None
8175 if sine_delays is not None:
8176 sine_delays = np.array(sine_delays)
8177 if sine_delays.ndim == 1:
8178 sine_delays = np.broadcast_to(sine_delays,self.shape+sine_delays.shape)
8180 if sine_decays is not None:
8181 sine_decays = np.array(sine_decays)
8182 if sine_decays.ndim == 1:
8183 sine_decays = np.broadcast_to(sine_decays,self.shape+sine_decays.shape)
8185 for index, srs_fn in self.ndenumerate():
8187 if sine_delays is not None:
8188 delays = sine_delays[index]
8189 else:
8190 delays = None
8192 if sine_decays is not None:
8193 decays = sine_decays[index]
8194 else:
8195 decays = None
8197 if sine_frequencies is None and sine_tone_range is None:
8198 this_sine_tone_range = [srs_fn.abscissa.min(), srs_fn.abscissa.max()]
8199 else:
8200 this_sine_tone_range = sine_tone_range
8202 srs_breakpoints = np.array((srs_fn.abscissa, srs_fn.ordinate)).T
8204 (acceleration_signal, velocity_signal, displacement_signal,
8205 all_frequencies, all_amplitudes, all_decays, all_delays,
8206 *plot_stuff) = sp_sds(
8207 sample_rate, block_size, sine_frequencies,
8208 this_sine_tone_range, sine_tone_per_octave, sine_amplitudes,
8209 decays, delays, None, srs_breakpoints,
8210 srs_damping, srs_type, compensation_frequency,
8211 compensation_decay, number_of_iterations, convergence,
8212 error_tolerance, tau, num_time_constants, decay_resolution,
8213 scale_factor, acceleration_factor, plot_results,
8214 srs_frequencies, ignore_compensation_pulse, verbose)
8216 acceleration[index] = acceleration_signal
8217 if return_displacement:
8218 displacement[index] = displacement_signal
8219 if return_velocity:
8220 velocity[index] = velocity_signal
8221 if return_srs:
8222 # Compute the SRS
8223 if srs_frequencies is None:
8224 this_srs_frequencies = all_frequencies[:-1]
8225 else:
8226 this_srs_frequencies = srs_frequencies
8227 this_srs, freq = sp_srs(acceleration_signal, 1/sample_rate,
8228 this_srs_frequencies, srs_damping, srs_type)
8229 if srs is None:
8230 srs = np.empty(self.shape+(this_srs.size,))
8231 srs[index] = this_srs
8232 if return_sine_table:
8233 if sine_table is None:
8234 sine_table = DecayedSineTable(self.shape, all_frequencies.size)
8235 sine_table[index] = decayed_sine_table(all_frequencies, all_amplitudes, all_decays, all_delays, srs_fn.coordinate)
8237 # Now convert to objects
8238 times = np.arange(block_size)/sample_rate
8239 acceleration = data_array(FunctionTypes.TIME_RESPONSE,
8240 times, acceleration, self.coordinate,
8241 self.comment1, self.comment2, self.comment3,
8242 self.comment4, self.comment5)
8243 return_values = (acceleration,)
8244 if return_velocity:
8245 velocity = data_array(FunctionTypes.TIME_RESPONSE,
8246 times, displacement, self.coordinate,
8247 self.comment1, self.comment2, self.comment3,
8248 self.comment4, self.comment5)
8249 return_values += (velocity,)
8250 if return_displacement:
8251 displacement = data_array(FunctionTypes.TIME_RESPONSE,
8252 times, displacement, self.coordinate,
8253 self.comment1, self.comment2, self.comment3,
8254 self.comment4, self.comment5)
8255 return_values += (displacement,)
8256 if return_srs:
8257 srs = data_array(FunctionTypes.SHOCK_RESPONSE_SPECTRUM,
8258 this_srs_frequencies, srs, self.coordinate,
8259 self.comment1, self.comment2, self.comment3,
8260 self.comment4, self.comment5)
8261 return_values += (srs,)
8262 if return_sine_table:
8263 return_values += (sine_table,)
8265 if len(return_values) == 1:
8266 return_values = return_values[0]
8268 return return_values
8270 def plot(self, one_axis: bool = True, subplots_kwargs: dict = {},
8271 plot_kwargs: dict = {}, abscissa_markers = None,
8272 abscissa_marker_labels = None, abscissa_marker_type = 'vline',
8273 abscissa_marker_plot_kwargs = {}):
8274 """
8275 Plot the shock response spectrum
8277 Parameters
8278 ----------
8279 one_axis : bool, optional
8280 Set to True to plot all data on one axis. Set to False to plot
8281 data on multiple subplots. one_axis can also be set to a
8282 matplotlib axis to plot data on an existing axis. The default is
8283 True.
8284 subplots_kwargs : dict, optional
8285 Keywords passed to the matplotlib subplots function to create the
8286 figure and axes. The default is {}.
8287 plot_kwargs : dict, optional
8288 Keywords passed to the matplotlib plot function. The default is {}.
8289 abscissa_markers : ndarray, optional
8290 Array containing abscissa values to mark on the plot to denote
8291 significant events.
8292 abscissa_marker_labels : str or ndarray
8293 Array of strings to label the abscissa_markers with, or
8294 alternatively a format string that accepts index and abscissa
8295 inputs (e.g. '{index:}: {abscissa:0.2f}'). By default no label
8296 will be applied.
8297 abscissa_marker_type : str
8298 The type of marker to use. This can either be the string 'vline'
8299 or a valid matplotlib symbol specifier (e.g. 'o', 'x', '.').
8300 abscissa_marker_plot_kwargs : dict
8301 Additional keyword arguments used when plotting the abscissa label
8302 markers.
8304 Returns
8305 -------
8306 axis : matplotlib axis or array of axes
8307 On which the data were plotted
8309 """
8310 if abscissa_markers is not None:
8311 if abscissa_marker_labels is None:
8312 abscissa_marker_labels = ['' for value in abscissa_markers]
8313 elif isinstance(abscissa_marker_labels,str):
8314 abscissa_marker_labels = [abscissa_marker_labels.format(
8315 index = i, abscissa = v) for i,v in enumerate(abscissa_markers)]
8317 if one_axis is True:
8318 figure, axis = plt.subplots(**subplots_kwargs)
8319 lines = axis.plot(self.flatten().abscissa.T, self.flatten().ordinate.T, **plot_kwargs)
8320 axis.set_yscale('log')
8321 axis.set_xscale('log')
8322 if abscissa_markers is not None:
8323 if abscissa_marker_type == 'vline':
8324 kwargs = {'color':'k'}
8325 kwargs.update(abscissa_marker_plot_kwargs)
8326 for value,label in zip(abscissa_markers,abscissa_marker_labels):
8327 axis.axvline(value, **kwargs)
8328 axis.annotate(label, xy = (value, axis.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
8329 axis.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
8330 else:
8331 for line in lines:
8332 x = line.get_xdata()
8333 y = line.get_ydata()
8334 marker_y = np.interp(abscissa_markers, x, y)
8335 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
8336 kwargs.update(abscissa_marker_plot_kwargs)
8337 axis.plot(abscissa_markers,marker_y,**kwargs)
8338 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
8339 axis.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
8340 elif one_axis is False:
8341 ncols = int(np.floor(np.sqrt(self.size)))
8342 nrows = int(np.ceil(self.size / ncols))
8343 figure, axis = plt.subplots(nrows, ncols, **subplots_kwargs)
8344 for i, (ax, (index, function)) in enumerate(zip(axis.flatten(), self.ndenumerate())):
8345 lines = ax.plot(function.abscissa.T, function.ordinate.T, **plot_kwargs)
8346 ax.set_ylabel('/'.join([str(v) for i, v in function.coordinate.ndenumerate()]))
8347 ax.set_yscale('log')
8348 ax.set_xscale('log')
8349 if abscissa_markers is not None:
8350 if abscissa_marker_type == 'vline':
8351 kwargs = {'color':'k'}
8352 kwargs.update(abscissa_marker_plot_kwargs)
8353 for value,label in zip(abscissa_markers,abscissa_marker_labels):
8354 ax.axvline(value, **kwargs)
8355 ax.annotate(label, xy = (value, ax.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
8356 ax.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
8357 else:
8358 for line in lines:
8359 x = line.get_xdata()
8360 y = line.get_ydata()
8361 marker_y = np.interp(abscissa_markers, x, y)
8362 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
8363 kwargs.update(abscissa_marker_plot_kwargs)
8364 ax.plot(abscissa_markers,marker_y,**kwargs)
8365 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
8366 ax.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
8367 for ax in axis.flatten()[i + 1:]:
8368 ax.remove()
8369 else:
8370 axis = one_axis
8371 lines = axis.plot(self.abscissa.T, self.ordinate.T, **plot_kwargs)
8372 if abscissa_markers is not None:
8373 if abscissa_marker_type == 'vline':
8374 kwargs = {'color':'k'}
8375 kwargs.update(abscissa_marker_plot_kwargs)
8376 for value,label in zip(abscissa_markers,abscissa_marker_labels):
8377 axis.axvline(value, **kwargs)
8378 axis.annotate(label, xy = (value, axis.get_ylim()[0]), rotation = 90, xytext=(4,4),textcoords='offset pixels',ha='left',va='bottom')
8379 axis.callbacks.connect('ylim_changed',_update_annotations_to_axes_bottom)
8380 else:
8381 for line in lines:
8382 x = line.get_xdata()
8383 y = line.get_ydata()
8384 marker_y = np.interp(abscissa_markers, x, y)
8385 kwargs = {'color':line.get_color(),'marker':abscissa_marker_type,'linewidth':0}
8386 kwargs.update(abscissa_marker_plot_kwargs)
8387 axis.plot(abscissa_markers,marker_y,**kwargs)
8388 for label,mx,my in zip(abscissa_marker_labels,abscissa_markers,marker_y):
8389 axis.annotate(label, xy=(mx,my), textcoords='offset pixels', xytext=(4,4), ha='left', va='bottom')
8390 return axis
8392 def mimo_inverse(self,transfer_function, sample_rate, block_size,
8393 srs_damping=0.03, num_time_constants = None, tau = None, sine_decays = None,
8394 rcond=None, accuracy_weight=1, input_weight=0,
8395 return_drive_signal=True, return_drive_table=False,
8396 return_projected_srs=False, return_optimization_result=False,
8397 return_complex_targets=False):
8398 """
8399 Computes an input signal that would recreate the specified SRS
8401 Computes an input signal that would recreate the specified SRSs if
8402 played into a system with the specified transfer functions. It uses
8403 a phase-matching approach to compute a preferred phasing between
8404 responses that are not specified by the SRS functions. It then uses
8405 the transfer functions to solve for drive signals that will achieve
8406 those desired responses.
8408 Parameters
8409 ----------
8410 transfer_function : TransferFunctionArray
8411 The transfer functions to use in the MIMO calculation
8412 sample_rate : float
8413 The number of samples per second in the output signal
8414 block_size : int
8415 The number of samples in the output signal
8416 srs_damping : float, optional
8417 The damping used in SRS computations. The default is 0.03.
8418 num_time_constants : int, optional
8419 Number of decay time constants in the signal. One of this, tau, or
8420 sine_decays must be specified.
8421 tau : float, optional
8422 The decay constant for the sine waves. One of this, num_time_constants,
8423 or sine_decays must be specified.
8424 sine_decays : ndarray, optional
8425 An array of sine decay terms as used in the decayed sine table. One
8426 of this, num_time_constants, or tau must be specified.
8427 rcond : float, optional
8428 A regularization parameter used on the MIMO inverse problem. The
8429 default is no regularization.
8430 accuracy_weight : float, optional
8431 A weighting factor to give to the accuracy of the MIMO solution.
8432 The default is 1.
8433 input_weight : float, optional
8434 A weighting factor to give to the magnitude of the drive signal in
8435 the MIMO solution. The default is 0.
8436 return_drive_signal : bool, optional
8437 If True, return the calculated drive signal. The default is True.
8438 return_drive_table : bool, optional
8439 If True, return a DecayedSineTable containing the parameters of the
8440 drive signal. The default is False.
8441 return_projected_srs : bool, optional
8442 If True, compute the response SRS achieved from the computed drive
8443 signal. The default is False.
8444 return_optimization_result : bool, optional
8445 If True, return the optimization results. The default is False.
8446 return_complex_targets : bool, optional
8447 If True, return the complex targets of the MIMO calculation.
8448 The default is False.
8450 Returns
8451 -------
8452 drive_signal : TimeHistoryArray
8453 A time history that when played through the transfer functions will
8454 produce the specified SRS. Only returned if return_drive_signal is
8455 True.
8456 drive_table : DecayedSineTable
8457 A table containing frequency, amplitude, delay, and decay parameters
8458 for each signal. Only returned if return_drive_table is True.
8459 projected_srs : ShockResponseSpectrumArray
8460 A SRS computed from the predicted response of playing the drive
8461 signal through the provided transfer functions. Only returned if
8462 return_projected_srs is True.
8463 optimization_result : list of OptimizationResult
8464 A set of results from the nonlinear optimizers. Only returned if
8465 return_optimization_result is True.
8466 complex_targets : ndarray
8467 The complex amplitudes of the response signals that were targeted
8468 by the MIMO computation. Only returned if return_complex_targets
8469 is True.
8471 """
8473 spec_channels = np.unique(self.coordinate)
8474 drive_channels = np.unique(transfer_function.reference_coordinate)
8475 transfer_function = transfer_function[outer_product(spec_channels,drive_channels)]
8477 # Define a function to do the optimization
8478 def optimize_phase_targets_pinv(A, b_amplitude, weight_accuracy=1.0, weight_magnitude=1.0, rcond=None, phi0 = None):
8479 """
8480 Optimize the phase targets of b to balance the accuracy of Ax ≈ b and the magnitude of x,
8481 using the pseudoinverse of A for computational efficiency.
8483 Parameters:
8484 A (ndarray): Complex matrix (m x n).
8485 b_amplitude (ndarray): Desired amplitudes of b (real-valued, length m).
8486 weight_accuracy (float): Weight for the accuracy term (default: 1.0).
8487 weight_magnitude (float): Weight for the magnitude term (default: 1.0).
8489 Returns:
8490 x_opt (ndarray): Optimal solution for x.
8491 b_opt (ndarray): Optimal b with optimized phase targets.
8492 result (OptimizeResult): Optimization result object from scipy.optimize.
8493 """
8494 m, n = A.shape
8496 # Precompute the pseudoinverse of A
8497 A_pinv = np.linalg.pinv(A,rcond=rcond)
8499 # Objective function: balance accuracy of Ax ≈ b and magnitude of x
8500 def objective(phi):
8501 # Construct b with the current phase
8502 b = b_amplitude * np.exp(1j * phi)
8504 # Compute x using the pseudoinverse
8505 x = A_pinv @ b
8507 # Compute accuracy term: ||Ax - b||_2^2
8508 Ax = A @ x
8509 accuracy_term = np.sum((np.abs(Ax) - np.abs(b))**2)
8511 # Compute magnitude term: ||x||_2^2
8512 magnitude_term = np.sum(np.abs(x)**2)
8514 # Weighted objective function
8515 return weight_accuracy * accuracy_term + weight_magnitude * magnitude_term
8517 # Initial guess for phi (zero phase)
8518 if phi0 is None:
8519 phi0 = np.zeros(m)
8521 # Optimize the phase of b
8522 result = minimize(objective, phi0, method='L-BFGS-B', bounds=[(-np.pi, np.pi)] * m)
8524 # Optimal phase and corresponding b
8525 phi_opt = result.x
8526 b_opt = b_amplitude * np.exp(1j * phi_opt)
8528 # Compute the optimal x using the pseudoinverse
8529 x_opt = A_pinv @ b_opt
8531 return x_opt, b_opt, result
8533 # Compute the sine table to determine the target amplitudes
8534 srs_frequencies = np.unique(self.abscissa)
8535 control_responses, control_table = self.sum_decayed_sines(
8536 sample_rate,block_size,
8537 srs_frequencies,
8538 srs_damping=srs_damping,
8539 num_time_constants=num_time_constants,
8540 tau=tau,
8541 sine_decays=sine_decays,
8542 return_sine_table=True,
8543 ignore_compensation_pulse=True)
8545 # Set up the initial optimization problem
8546 x_opt = []
8547 b_opt = []
8548 result = []
8550 A_all = transfer_function.interpolate(srs_frequencies).ordinate.transpose(2,0,1)
8551 b_all = control_table.amplitude[:,:-1].T
8553 # Solve for the specification phases that result in the best accuracy and force
8554 for A,b_amplitude in zip(A_all,b_all):
8555 x_o, b_o, r_o = optimize_phase_targets_pinv(A, b_amplitude,rcond=rcond, weight_accuracy=accuracy_weight,weight_magnitude=input_weight)
8556 x_opt.append(x_o)
8557 b_opt.append(b_o)
8558 result.append(r_o)
8560 x_opt = np.array(x_opt)
8561 b_opt = np.array(b_opt)
8563 # Now that we know the phases, recompute the SRSs with adjusted phases
8564 # to get better amplitude estimates
8565 phases = np.angle(b_opt).T
8566 delays = -phases/(2*np.pi*srs_frequencies)
8567 decays = control_table.decay[:,:-1]
8569 control_responses_phase_update, control_tables_phase_update = self.sum_decayed_sines(
8570 sample_rate,block_size,
8571 srs_frequencies,
8572 srs_damping=srs_damping,
8573 return_sine_table=True,
8574 sine_decays=decays,
8575 sine_delays=delays,
8576 ignore_compensation_pulse = True)
8578 # Now again solve for the drive signals to match this preferred phasing
8579 x_opt = []
8580 result = []
8581 angle_guess = np.angle(b_opt)
8582 b_opt2 = []
8584 for A,b,phi0 in zip(A_all,b_all,angle_guess):
8585 x_o, b_o, r_o = optimize_phase_targets_pinv(A, np.abs(b),rcond=rcond, phi0=phi0, weight_accuracy=accuracy_weight,weight_magnitude=input_weight)
8586 x_opt.append(x_o)
8587 b_opt2.append(b_o)
8588 result.append(r_o)
8590 x_opt = np.array(x_opt).T
8591 b_opt2 = np.array(b_opt2).T
8593 # Extract the drive amplitudes and phases
8594 drive_amplitudes = np.abs(x_opt)
8595 drive_phases = np.angle(x_opt)
8596 drive_delays = -drive_phases/(2*np.pi*srs_frequencies)
8597 drive_decays = control_tables_phase_update.decay[0,:-1]
8599 # Create the drive table and signals
8600 drive_table = decayed_sine_table(srs_frequencies, drive_amplitudes, drive_decays, drive_delays, drive_channels[:,np.newaxis])
8601 drive_signal = drive_table.construct_signal(sample_rate,block_size)
8603 return_vals = []
8605 if return_drive_signal:
8606 return_vals.append(drive_signal)
8607 if return_drive_table:
8608 return_vals.append(drive_table)
8609 if return_projected_srs:
8610 response = drive_signal.mimo_forward(transfer_function)
8611 response_srs = response.srs(frequencies=srs_frequencies,damping=srs_damping)
8612 return_vals.append(response_srs)
8613 if return_optimization_result:
8614 return_vals.append(result)
8615 if return_complex_targets:
8616 return_vals.append(b_opt2)
8618 return_vals=tuple(return_vals)
8619 if len(return_vals) == 1:
8620 return_vals = return_vals[0]
8622 return return_vals
8624def shock_response_spectrum_array(abscissa,ordinate,coordinate,
8625 comment1='',comment2='',
8626 comment3='',comment4='',comment5=''):
8627 """
8628 Helper function to create a ShockResponseSpectrumArray object.
8630 All input arguments to this function are allowed to broadcast to create the
8631 final data in the ShockResponseSpectrumArray object.
8633 Parameters
8634 ----------
8635 abscissa : np.ndarray
8636 Numpy array specifying the abscissa of the function
8637 ordinate : np.ndarray
8638 Numpy array specifying the ordinate of the function
8639 coordinate : CoordinateArray
8640 Coordinate for each data in the data array
8641 comment1 : np.ndarray, optional
8642 Comment used to describe the data in the data array. The default is ''.
8643 comment2 : np.ndarray, optional
8644 Comment used to describe the data in the data array. The default is ''.
8645 comment3 : np.ndarray, optional
8646 Comment used to describe the data in the data array. The default is ''.
8647 comment4 : np.ndarray, optional
8648 Comment used to describe the data in the data array. The default is ''.
8649 comment5 : np.ndarray, optional
8650 Comment used to describe the data in the data array. The default is ''.
8652 Returns
8653 -------
8654 obj : ShockResponseSpectrumArray
8655 The constructed ShockResponseSpectrumArray object
8656 """
8657 return data_array(FunctionTypes.SHOCK_RESPONSE_SPECTRUM,abscissa,ordinate,
8658 coordinate, comment1,comment2,comment3,comment4,comment5)
8661_function_type_class_map = {FunctionTypes.GENERAL: NDDataArray,
8662 FunctionTypes.TIME_RESPONSE: TimeHistoryArray,
8663 FunctionTypes.AUTOSPECTRUM: PowerSpectrumArray,
8664 FunctionTypes.CROSSSPECTRUM: PowerSpectrumArray,
8665 FunctionTypes.FREQUENCY_RESPONSE_FUNCTION: TransferFunctionArray,
8666 FunctionTypes.TRANSMISIBILITY: TransmissibilityArray,
8667 FunctionTypes.COHERENCE: CoherenceArray,
8668 FunctionTypes.AUTOCORRELATION: CorrelationArray,
8669 FunctionTypes.CROSSCORRELATION: CorrelationArray,
8670 FunctionTypes.POWER_SPECTRAL_DENSITY: PowerSpectralDensityArray,
8671 FunctionTypes.ENERGY_SPECTRAL_DENSITY: PowerSpectralDensityArray,
8672 # FunctionTypes.PROBABILITY_DENSITY_FUNCTION, : ,
8673 FunctionTypes.SPECTRUM: SpectrumArray,
8674 # FunctionTypes.CUMULATIVE_FREQUENCY_DISTRIBUTION, : ,
8675 # FunctionTypes.PEAKS_VALLEY, : ,
8676 # FunctionTypes.STRESS_PER_CYCLE, : ,
8677 # FunctionTypes.STRAIN_PER_CYCLE, : ,
8678 # FunctionTypes.ORBIT, : ,
8679 FunctionTypes.MODE_INDICATOR_FUNCTION: ModeIndicatorFunctionArray,
8680 # FunctionTypes.FORCE_PATTERN, : ,
8681 # FunctionTypes.PARTIAL_POWER, : ,
8682 # FunctionTypes.PARTIAL_COHERENCE, : ,
8683 # FunctionTypes.EIGENVALUE, : ,
8684 # FunctionTypes.EIGENVECTOR, : ,
8685 FunctionTypes.SHOCK_RESPONSE_SPECTRUM: ShockResponseSpectrumArray,
8686 # FunctionTypes.FINITE_IMPULSE_RESPONSE_FILTER, : ,
8687 FunctionTypes.MULTIPLE_COHERENCE: MultipleCoherenceArray,
8688 # FunctionTypes.ORDER_FUNCTION, : ,
8689 # FunctionTypes.PHASE_COMPENSATION, : ,
8690 FunctionTypes.IMPULSE_RESPONSE_FUNCTION: ImpulseResponseFunctionArray,
8691 }
8694def data_array(data_type, abscissa, ordinate, coordinate, comment1='', comment2='', comment3='', comment4='', comment5=''):
8695 """
8696 Helper function to create a data array object.
8698 All input arguments to this function are allowed to broadcast to create the
8699 final data in the NDDataArray object.
8701 Parameters
8702 ----------
8703 data_type : FunctionTypes
8704 Type of data array that will be created
8705 abscissa : np.ndarray
8706 Numpy array specifying the abscissa of the function
8707 ordinate : np.ndarray
8708 Numpy array specifying the ordinate of the function
8709 coordinate : CoordinateArray
8710 Coordinate for each data in the data array
8711 comment1 : np.ndarray, optional
8712 Comment used to describe the data in the data array. The default is ''.
8713 comment2 : np.ndarray, optional
8714 Comment used to describe the data in the data array. The default is ''.
8715 comment3 : np.ndarray, optional
8716 Comment used to describe the data in the data array. The default is ''.
8717 comment4 : np.ndarray, optional
8718 Comment used to describe the data in the data array. The default is ''.
8719 comment5 : np.ndarray, optional
8720 Comment used to describe the data in the data array. The default is ''.
8722 Returns
8723 -------
8724 obj : NDDataArray or subclass
8725 The constructed NDDataArray (or subclass) object
8726 """
8727 cls = _function_type_class_map[data_type]
8728 nelem = ordinate.shape[-1]
8729 shape = ordinate.shape[:-1]
8730 if data_type is FunctionTypes.GENERAL:
8731 obj = cls(shape, nelem, coordinate.shape[-1])
8732 else:
8733 obj = cls(shape, nelem)
8734 obj.ordinate = ordinate
8735 obj.abscissa = abscissa
8736 obj.comment1 = comment1
8737 obj.comment2 = comment2
8738 obj.comment3 = comment3
8739 obj.comment4 = comment4
8740 obj.comment5 = comment5
8741 num_coords = obj.dtype['coordinate'].shape[0]
8742 if coordinate.ndim == 0:
8743 obj.coordinate[:] = coordinate
8744 else:
8745 obj.coordinate[:] = coordinate[..., :num_coords]
8746 return obj
8749from_unv = NDDataArray.from_unv
8750from_uff = from_unv
8751load = NDDataArray.load
8754def from_imat_struct(imat_fn_struct, squeeze=True):
8755 """
8756 Constructs a NDDataArray from an imat_fn class saved to a Matlab structure
8758 In IMAT, a structure can be created from an `imat_fn` by using the get()
8759 function. This can then be saved to a .mat file and loaded using
8760 `scipy.io.loadmat`. The output from loadmat can be passed into this function
8762 Parameters
8763 ----------
8764 imat_fn_struct : np.ndarray
8765 structure from loadmat containing data from an imat_fn
8766 squeeze : bool, optional
8767 If True, return a single NDDataArray object or subclass if only one
8768 function type exists in the data. Otherwise, it will return a list of
8769 length one. If more than one function type exists, a list of
8770 NDDataArray objects will be returned regardless of the value of
8771 `squeeze`. Default is True.
8773 Returns
8774 -------
8775 return_functions : NDDataArray or list of NDDataArray
8776 Returns a list of NDDataArray objects if `squeeze` is false, or a single
8777 NDDataArray object if `squeeze` is True, unless there are multiple
8778 function types stored in the structure.
8780 """
8782 # Get function types
8783 fn_types = np.array(imat_fn_struct['FunctionType'][0, 0].tolist())
8784 fn_types = fn_types.reshape(*fn_types.shape[:-1])
8785 # Separate into the different types of functions
8786 function_type_dict = {}
8787 for i, fn_type in enumerate(fn_types.flatten()):
8788 fn_type_enum = _imat_function_type_map[fn_type]
8789 if fn_type_enum not in function_type_dict:
8790 function_type_dict[fn_type_enum] = []
8791 function_type_dict[fn_type_enum].append(i)
8792 return_functions = []
8793 abscissa = imat_fn_struct['Abscissa'][0, 0]
8794 abscissa = abscissa.reshape(abscissa.shape[0], -1)
8795 ordinate = imat_fn_struct['Ordinate'][0, 0]
8796 ordinate = ordinate.reshape(ordinate.shape[0], -1)
8797 reference_coords = np.array(imat_fn_struct['ReferenceCoord'][0, 0].tolist())
8798 if reference_coords.size > 0:
8799 reference_coords = reference_coords.reshape(*reference_coords.shape[:-1]).flatten()
8800 else:
8801 reference_coords = np.zeros(reference_coords.shape[:-1], dtype='<U1').flatten()
8802 response_coords = np.array(imat_fn_struct['ResponseCoord'][0, 0].tolist())
8803 if response_coords.size > 0:
8804 response_coords = response_coords.reshape(*response_coords.shape[:-1]).flatten()
8805 else:
8806 response_coords = np.zeros(response_coords.shape[:-1], dtype='<U1').flatten()
8807 comment_1 = np.array(imat_fn_struct['IDLine1'][0, 0].tolist())
8808 if comment_1.size > 0:
8809 comment_1 = comment_1.reshape(*comment_1.shape[:-1]).flatten()
8810 else:
8811 comment_1 = np.zeros(comment_1.shape[:-1], dtype='<U1').flatten()
8812 comment_2 = np.array(imat_fn_struct['IDLine2'][0, 0].tolist())
8813 if comment_2.size > 0:
8814 comment_2 = comment_1.reshape(*comment_2.shape[:-1]).flatten()
8815 else:
8816 comment_2 = np.zeros(comment_2.shape[:-1], dtype='<U1').flatten()
8817 comment_3 = np.array(imat_fn_struct['IDLine3'][0, 0].tolist())
8818 if comment_3.size > 0:
8819 comment_3 = comment_3.reshape(*comment_3.shape[:-1]).flatten()
8820 else:
8821 comment_3 = np.zeros(comment_3.shape[:-1], dtype='<U1').flatten()
8822 comment_4 = np.array(imat_fn_struct['IDLine4'][0, 0].tolist())
8823 if comment_4.size > 0:
8824 comment_4 = comment_4.reshape(*comment_4.shape[:-1]).flatten()
8825 else:
8826 comment_4 = np.zeros(comment_4.shape[:-1], dtype='<U1').flatten()
8827 comment_5 = np.zeros(comment_4.shape, dtype='<U1')
8828 all_coords = coordinate_array(string_array=np.concatenate(
8829 (response_coords[:, np.newaxis], reference_coords[:, np.newaxis]), axis=-1))
8830 for fn_type, indices in function_type_dict.items():
8831 return_functions.append(
8832 data_array(fn_type, abscissa[:, indices].T, ordinate[:, indices].T,
8833 all_coords[indices], comment_1[indices], comment_2[indices],
8834 comment_3[indices], comment_4[indices], comment_5[indices])
8835 )
8836 if len(return_functions) == 1 and squeeze:
8837 return_functions = return_functions[0]
8838 return return_functions
8841class DecayedSineTable(SdynpyArray):
8842 """Structure for storing sum-of-decayed-sines information
8843 """
8845 def __new__(subtype, shape, num_elements, buffer=None, offset=0, strides=None, order=None):
8846 # Create the ndarray instance of our type, given the usual
8847 # ndarray input arguments. This will call the standard
8848 # ndarray constructor, but return an object of our type.
8849 # It also triggers a call to __array_finalize__
8850 data_dtype = [
8851 ('frequency', 'float64', num_elements),
8852 ('amplitude', 'float64', num_elements),
8853 ('decay', 'float64', num_elements),
8854 ('delay', 'float64', num_elements),
8855 ('comment1', '<U80'),
8856 ('comment2', '<U80'),
8857 ('comment3', '<U80'),
8858 ('comment4', '<U80'),
8859 ('comment5', '<U80'),
8860 ('coordinate', CoordinateArray.data_dtype, (1,)),
8861 ]
8862 obj = super(SdynpyArray, subtype).__new__(subtype, shape,
8863 data_dtype, buffer, offset, strides, order)
8864 # Finally, we must return the newly created object:
8865 return obj
8867 def __getitem__(self, key):
8868 output = super().__getitem__(key)
8869 if isinstance(key, str) and key == 'coordinate':
8870 return output.view(CoordinateArray)
8871 else:
8872 return output
8874 def construct_signal(self, sample_rate, block_size):
8875 output_abscissa = np.arange(block_size)/sample_rate
8876 output_ordinate = np.empty(self.shape+(block_size,))
8877 for index, table in self.ndenumerate():
8878 signal = sum_decayed_sines_reconstruction(
8879 table.frequency, table.amplitude, table.decay, table.delay,
8880 sample_rate, block_size)
8881 output_ordinate[index] = signal
8882 return data_array(FunctionTypes.TIME_RESPONSE, output_abscissa,
8883 output_ordinate, self.coordinate, self.comment1,
8884 self.comment2, self.comment3, self.comment4,
8885 self.comment5)
8887 def construct_velocity(self, sample_rate, block_size, acceleration_factor=1):
8888 output_abscissa = np.arange(block_size)/sample_rate
8889 output_ordinate = np.empty(self.shape+(block_size,))
8890 for index, table in self.ndenumerate():
8891 signal = sum_decayed_sines_displacement_velocity(
8892 table.frequency, table.amplitude, table.decay, table.delay,
8893 sample_rate, block_size, acceleration_factor)[0]
8894 output_ordinate[index] = signal
8895 return data_array(FunctionTypes.TIME_RESPONSE, output_abscissa,
8896 output_ordinate, self.coordinate, self.comment1,
8897 self.comment2, self.comment3, self.comment4,
8898 self.comment5)
8900 def construct_displacement(self, sample_rate, block_size, acceleration_factor=1):
8901 output_abscissa = np.arange(block_size)/sample_rate
8902 output_ordinate = np.empty(self.shape+(block_size,))
8903 for index, table in self.ndenumerate():
8904 signal = sum_decayed_sines_displacement_velocity(
8905 table.frequency, table.amplitude, table.decay, table.delay,
8906 sample_rate, block_size, acceleration_factor)[1]
8907 output_ordinate[index] = signal
8908 return data_array(FunctionTypes.TIME_RESPONSE, output_abscissa,
8909 output_ordinate, self.coordinate, self.comment1,
8910 self.comment2, self.comment3, self.comment4,
8911 self.comment5)
8914def decayed_sine_table(frequency, amplitude, decay, delay, coordinate, comment1='', comment2='', comment3='', comment4='', comment5=''):
8915 """
8916 Helper function to create a DecayedSineTable object.
8918 Parameters
8919 ----------
8920 frequency : np.ndarray
8921 Frequencies of the decaying sine waves
8922 amplitude : np.ndarray
8923 Amplitudes of the decaying sine waves.
8924 decay : np.ndarray
8925 Damping values of the decaying sine waves.
8926 delay : np.ndarray
8927 Delay values of the decaying sine waves.
8928 coordinate : np.ndarray
8929 Coordinate information for each of the decaying sine waves. Must match
8930 the coordinate shape of a TimeHistoryArray, which means it must have
8931 shape (...,1)
8932 comment1 : np.ndarray, optional
8933 Comment used to describe the data in the data array. The default is ''.
8934 comment2 : np.ndarray, optional
8935 Comment used to describe the data in the data array. The default is ''.
8936 comment3 : np.ndarray, optional
8937 Comment used to describe the data in the data array. The default is ''.
8938 comment4 : np.ndarray, optional
8939 Comment used to describe the data in the data array. The default is ''.
8940 comment5 : np.ndarray, optional
8941 Comment used to describe the data in the data array. The default is ''.
8943 Returns
8944 -------
8945 SineTable :
8946 A SineTable object containing the specified information
8948 """
8949 coordinate = np.atleast_1d(coordinate)
8950 if coordinate.shape[-1] != 1:
8951 raise ValueError('`coordinate` must have shape (...,1)')
8952 *other, num_elements = frequency.shape
8953 shape = coordinate.shape[:-1]
8954 st = DecayedSineTable(shape, num_elements)
8955 st.frequency = frequency
8956 st.amplitude = amplitude
8957 st.decay = decay
8958 st.delay = delay
8959 st.coordinate = coordinate
8960 st.comment1 = comment1
8961 st.comment2 = comment2
8962 st.comment3 = comment3
8963 st.comment4 = comment4
8964 st.comment5 = comment5
8965 return st
8968class ComplexType(Enum):
8969 """Enumeration containing the various ways to plot complex data"""
8970 REAL = 0
8971 IMAGINARY = 1
8972 MAGNITUDE = 2
8973 PHASE = 3
8974 REALIMAG = 4
8975 MAGPHASE = 5
8978class GUIPlot(QMainWindow):
8979 """An iteractive plot window allowing users to visualize data"""
8981 def __init__(self, *data_to_plot, **labeled_data_to_plot):
8982 """
8983 Create a GUIPlot window to visualize data.
8985 Multiple datasets can be overlaid by providing additional datasets as
8986 arguments. Position arguments will be labelled generically in the
8987 legend. Keyword arguments will be labelled with their keywords with
8988 `_` replaced by ` `.
8990 Parameters
8991 ----------
8992 *data_to_plot : NDDataArray
8993 Data to visualize. Data passed by positional argument will be
8994 labeled generically in the legend
8995 **labeled_data_to_plot : NDDataArray
8996 Data to visualize. Data passed by keyword argument will be
8997 labeled with its keyword
8998 abscissa_markers : np.ndarray
8999 Abscissa values at which markers will be placed. If not specified,
9000 no markers will be added. Markers will be added to all plotted
9001 curves if this argument is passed. To add markers to just a
9002 specific plotted data, pass the argument `abscissa_markers_*` where
9003 `*` is replaced with either the index of the data that was passed
9004 via a positional argument, or the keyword of the data that was
9005 passed via a keyword argument. Must be passed as a keyword argument.
9006 abscissa_marker_labels : iterable
9007 Labels that will be applied to the markers. If not specified, no
9008 label will be applied. If a single string is passed, it will be
9009 passed to the `.format` method with keyword arguments `index` and
9010 `abscissa`. This marker label will be used for all plotted
9011 curves if this argument is passed. To add markers to just a
9012 specific plotted data, pass the argument `abscissa_marker_labels_*` where
9013 `*` is replaced with either the index of the data that was passed
9014 via a positional argument, or the keyword of the data that was
9015 passed via a keyword argument. Must be passed as a keyword argument.
9016 abscissa_marker_type : str:
9017 The type of marker that will be applied. Can be 'vline' for a
9018 vertical line across the axis, or it can be a pyqtgraph symbol specifier
9019 (e.g. 'x', 'o', 'star', etc.) which will be placed on the plotted curves.
9020 If not specified, a vertical line will be used.
9021 This marker type will be used for all plotted
9022 curves if this argument is passed. To add markers to just a
9023 specific plotted data, pass the argument `abscissa_marker_type_*` where
9024 `*` is replaced with either the index of the data that was passed
9025 via a positional argument, or the keyword of the data that was
9026 passed via a keyword argument. Must be passed as a keyword argument.
9028 Returns
9029 -------
9030 None.
9032 """
9033 # Parse the dataset arguments
9034 self._parse_arguments(data_to_plot,labeled_data_to_plot)
9035 # Now go through and reshape the data so it's all the same size
9036 first_data = [v for v in self.data_dictionary.values()][0]
9037 self.data_original_shape = first_data.shape
9038 self.coordinates = first_data.coordinate
9039 function_type = first_data.function_type
9040 for key in self.data_dictionary:
9041 if not np.all(self.coordinates.flatten() == self.data_dictionary[key].coordinate.flatten()):
9042 print('Warning: Coordinates not consistent for dataset {:}'.format(key))
9043 self.data_dictionary[key] = self.data_dictionary[key].flatten()
9044 super(GUIPlot, self).__init__()
9045 uic.loadUi(os.path.join(os.path.abspath(os.path.dirname(
9046 os.path.abspath(__file__))), 'GUIPlot.ui'), self)
9047 # Set up the table
9048 for index, fn in first_data.ndenumerate():
9049 this_row = self.tableWidget.rowCount()
9050 self.tableWidget.insertRow(this_row)
9051 try:
9052 self.tableWidget.item(this_row, 0).setText(str(index))
9053 except AttributeError:
9054 item = QtWidgets.QTableWidgetItem(str(index))
9055 self.tableWidget.setItem(this_row, 0, item)
9056 try:
9057 self.tableWidget.item(this_row, 1).setText(str(fn.response_coordinate))
9058 except AttributeError:
9059 item = QtWidgets.QTableWidgetItem(str(fn.response_coordinate))
9060 self.tableWidget.setItem(this_row, 1, item)
9061 if fn.dtype['coordinate'].shape[0] > 1:
9062 try:
9063 self.tableWidget.item(this_row, 2).setText(str(fn.reference_coordinate))
9064 except AttributeError:
9065 item = QtWidgets.QTableWidgetItem(str(fn.reference_coordinate))
9066 self.tableWidget.setItem(this_row, 2, item)
9067 try:
9068 self.tableWidget.item(this_row, 3).setText(
9069 str(_imat_function_type_inverse_map[fn.function_type]))
9070 except AttributeError:
9071 item = QtWidgets.QTableWidgetItem(
9072 str(_imat_function_type_inverse_map[fn.function_type]))
9073 self.tableWidget.setItem(this_row, 3, item)
9074 self.tableWidget.resizeColumnsToContents()
9075 # Set up color map
9076 if self.number_of_datasets == 2:
9077 self.cm = cm.tab20
9078 self.cm_mod = 20
9079 elif self.number_of_datasets == 3:
9080 # Combine tab20b and tab20c
9081 tab_b = cm.get_cmap('tab20b', 15)(np.linspace(0, 1, 15))
9082 tab_c = cm.get_cmap('tab20c', 15)(np.linspace(0, 1, 15))
9083 self.cm = ListedColormap(np.concatenate((tab_c, tab_b), axis=0))
9084 self.cm_mod = 30
9085 elif self.number_of_datasets == 4:
9086 # Combine tab20b and tab20c
9087 tab_b = cm.get_cmap('tab20b', 20)(np.linspace(0, 1, 20))
9088 tab_c = cm.get_cmap('tab20c', 20)(np.linspace(0, 1, 20))
9089 self.cm = ListedColormap(np.concatenate((tab_c, tab_b), axis=0))
9090 self.cm_mod = 40
9091 else:
9092 self.cm = cm.tab10
9093 self.cm_mod = 10
9094 # Adjust the default plotting
9095 if function_type in [FunctionTypes.GENERAL, FunctionTypes.TIME_RESPONSE,
9096 FunctionTypes.COHERENCE, FunctionTypes.AUTOCORRELATION,
9097 FunctionTypes.CROSSCORRELATION, FunctionTypes.MODE_INDICATOR_FUNCTION,
9098 FunctionTypes.MULTIPLE_COHERENCE]:
9099 complex_type = ComplexType.REAL
9100 self.abscissa_log = False
9101 self.ordinate_log = False
9102 elif function_type in [FunctionTypes.AUTOSPECTRUM, FunctionTypes.POWER_SPECTRAL_DENSITY,
9103 FunctionTypes.ENERGY_SPECTRAL_DENSITY]:
9104 complex_type = ComplexType.MAGNITUDE
9105 self.abscissa_log = False
9106 self.ordinate_log = True
9107 elif function_type in [FunctionTypes.CROSSSPECTRUM, FunctionTypes.FREQUENCY_RESPONSE_FUNCTION,
9108 FunctionTypes.TRANSMISIBILITY, FunctionTypes.SPECTRUM]:
9109 complex_type = ComplexType.MAGPHASE
9110 self.abscissa_log = False
9111 self.ordinate_log = True
9112 elif function_type in [FunctionTypes.SHOCK_RESPONSE_SPECTRUM]:
9113 complex_type = ComplexType.MAGNITUDE
9114 self.abscissa_log = True
9115 self.ordinate_log = True
9116 else:
9117 # print('Unknown Function Type {:}'.format(self.data[0].function_type))
9118 complex_type = ComplexType.REAL
9119 self.abscissa_log = False
9120 self.ordinate_log = False
9121 self.complex_types = {ComplexType.IMAGINARY: self.actionImaginary,
9122 ComplexType.MAGNITUDE: self.actionMagnitude,
9123 ComplexType.MAGPHASE: self.actionMagnitude_Phase,
9124 ComplexType.PHASE: self.actionPhase,
9125 ComplexType.REAL: self.actionReal,
9126 ComplexType.REALIMAG: self.actionReal_Imag}
9127 self.complex_function_maps = {ComplexType.IMAGINARY: (np.imag,),
9128 ComplexType.MAGNITUDE: (np.abs,),
9129 ComplexType.MAGPHASE: (np.angle, np.abs),
9130 ComplexType.PHASE: (np.angle,),
9131 ComplexType.REAL: (np.real,),
9132 ComplexType.REALIMAG: (np.real, np.imag)}
9133 self.complex_labels = {np.imag: 'Imag',
9134 np.abs: 'Mag',
9135 np.angle: 'Angle',
9136 np.real: 'Real'}
9137 for ct, action in self.complex_types.items():
9138 action.setChecked(False)
9139 self.complex_types[complex_type].setChecked(True)
9140 self.actionAbscissa_Log.setChecked(self.abscissa_log)
9141 self.actionOrdinate_Log.setChecked(self.ordinate_log)
9142 self.menuShare_Axes.setEnabled(False)
9143 self.update_button.setEnabled(False)
9144 self.actionOverlay.setEnabled(False) # TODO Remove when you implement non-overlapping plots
9145 self.connect_callbacks()
9146 # Set the first plot
9147 self.tableWidget.selectRow(0)
9148 self.setWindowTitle('GUIPlot')
9149 self.show()
9151 def _parse_arguments(self,data_to_plot,labeled_data_to_plot):
9152 self.data_dictionary = {}
9153 self.marker_data = {}
9154 for i, dataset in enumerate(data_to_plot):
9155 self.data_dictionary['Dataset {:}'.format(i+1)] = dataset
9156 for key, dataset in labeled_data_to_plot.items():
9157 if key == 'abscissa_markers':
9158 if not None in self.marker_data:
9159 self.marker_data[None] = [None,None,None]
9160 self.marker_data[None][0] = dataset
9161 elif key == 'abscissa_marker_labels':
9162 if not None in self.marker_data:
9163 self.marker_data[None] = [None,None,None]
9164 self.marker_data[None][1] = dataset
9165 elif key == 'abscissa_marker_type':
9166 if not None in self.marker_data:
9167 self.marker_data[None] = [None,None,None]
9168 self.marker_data[None][2] = dataset
9169 elif key[:17] == 'abscissa_markers_':
9170 marker_key = key.replace('abscissa_markers_','')
9171 try:
9172 marker_key = int(marker_key)
9173 marker_key = 'Dataset {:}'.format(marker_key+1)
9174 except ValueError:
9175 marker_key = marker_key.replace('_',' ')
9176 if not marker_key in self.marker_data:
9177 self.marker_data[marker_key] = [None,None,None]
9178 self.marker_data[marker_key][0] = dataset
9179 elif key[:23] == 'abscissa_marker_labels_':
9180 marker_key = key.replace('abscissa_marker_labels_','')
9181 try:
9182 marker_key = int(marker_key)
9183 marker_key = 'Dataset {:}'.format(marker_key+1)
9184 except ValueError:
9185 marker_key = marker_key.replace('_',' ')
9186 if not marker_key in self.marker_data:
9187 self.marker_data[marker_key] = [None,None,None]
9188 self.marker_data[marker_key][1] = dataset
9189 elif key[:21] == 'abscissa_marker_type_':
9190 marker_key = key.replace('abscissa_marker_type_','')
9191 try:
9192 marker_key = int(marker_key)
9193 marker_key = 'Dataset {:}'.format(marker_key+1)
9194 except ValueError:
9195 marker_key = marker_key.replace('_',' ')
9196 if not marker_key in self.marker_data:
9197 self.marker_data[marker_key] = [None,None,None]
9198 self.marker_data[marker_key][2] = dataset
9199 else:
9200 self.data_dictionary[key.replace('_', ' ')] = dataset
9201 self.number_of_datasets = len(self.data_dictionary)
9202 if self.number_of_datasets == 0:
9203 raise ValueError('At least one dataset must be provided!')
9205 def connect_callbacks(self):
9206 """
9207 Connects the callback functions to events
9209 Returns
9210 -------
9211 None.
9213 """
9214 self.tableWidget.itemSelectionChanged.connect(self.selection_changed)
9215 self.update_button.clicked.connect(self.update)
9216 self.actionImaginary.triggered.connect(self.set_imaginary)
9217 self.actionMagnitude.triggered.connect(self.set_magnitude)
9218 self.actionMagnitude_Phase.triggered.connect(self.set_magnitude_phase)
9219 self.actionPhase.triggered.connect(self.set_phase)
9220 self.actionReal.triggered.connect(self.set_real)
9221 self.actionReal_Imag.triggered.connect(self.set_real_imag)
9222 self.actionOrdinate_Log.triggered.connect(self.update_ordinate_log)
9223 self.actionAbscissa_Log.triggered.connect(self.update_abscissa_log)
9224 self.autoupdate_checkbox.clicked.connect(self.update_checkbox)
9225 self.linewidth_selector.valueChanged.connect(self.update)
9226 self.symbolsize_selector.valueChanged.connect(self.update)
9228 def update(self):
9229 """
9230 Updates the figure in the GUIPlot
9232 Returns
9233 -------
9234 None.
9236 """
9237 # print('Updating')
9238 select = self.tableWidget.selectionModel()
9239 # print('Has Selection {:}'.format(select.hasSelection()))
9240 row_indices = [val.row() for val in select.selectedRows()]
9241 # print('Rows Selected {:}'.format(row_indices))
9242 # Check if we want to plot them all on one plot
9243 # Get existing xaxis to keep it consistent
9244 try:
9245 xrange = self.graphicsLayoutWidget.getItem(0, 0).getViewBox().viewRange()[0]
9246 except AttributeError:
9247 xrange = None
9248 # print(xrange)
9249 self.graphicsLayoutWidget.clear()
9250 if self.actionOverlay.isChecked():
9251 # print('Single Plot')
9252 # Figure out which is checked
9253 checked_complex_type = [key for key,
9254 val in self.complex_types.items() if val.isChecked()][0]
9255 plots = []
9256 for i, complex_fn in enumerate(self.complex_function_maps[checked_complex_type]):
9257 plot = self.graphicsLayoutWidget.addPlot(
9258 i, 0, labels={'left': self.complex_labels[complex_fn]})
9259 if i > 0:
9260 plot.setXLink(plots[0])
9261 else:
9262 plot.addLegend()
9263 for j, index in enumerate(row_indices):
9264 original_index = np.unravel_index(index, self.data_original_shape)
9265 for k, (label, dataset) in enumerate(self.data_dictionary.items()):
9266 data_entry = dataset[index]
9267 legend_entry = '{:} {:} {:}'.format(
9268 tuple([str(v) for v in original_index]),
9269 data_entry.coordinate, label).replace("'", '')
9270 pen = pyqtgraph.mkPen(
9271 color=[int(255 * v) for v in self.cm((j * (self.number_of_datasets) + k) % self.cm_mod)],
9272 width=self.linewidth_selector.value())
9273 plot.plot(x=data_entry.abscissa, y=complex_fn(data_entry.ordinate) *
9274 (180 / np.pi if complex_fn is np.angle else 1), name=legend_entry, pen=pen)
9275 # Now handle the markers
9276 # First see if there is a matching marker set
9277 if label in self.marker_data:
9278 marker_abscissa, marker_labels, marker_type = self.marker_data[label]
9279 else:
9280 marker_abscissa, marker_labels, marker_type = [None,None,None]
9281 # Now handle if things are missing
9282 if marker_abscissa is None and None in self.marker_data:
9283 marker_abscissa = self.marker_data[None][0]
9284 if marker_labels is None and None in self.marker_data:
9285 marker_labels = self.marker_data[None][1]
9286 if marker_type is None and None in self.marker_data:
9287 marker_type = self.marker_data[None][2]
9288 # Now finally go back to the defaults if they still are
9289 # not defined
9290 if marker_abscissa is not None: # If marker_abscissa is not defined, we don't plot markers
9291 # The default marker is a vertical line
9292 if marker_type is None:
9293 marker_type = 'vline'
9294 # Parse out special values of the marker_labels
9295 if isinstance(marker_labels,str):
9296 marker_labels = [marker_labels.format(index=i,abscissa=a) for i,a in enumerate(marker_abscissa)]
9297 # Now that we have all of the data we can plot it.
9298 if marker_type == 'vline':
9299 if marker_labels is None:
9300 marker_labels = [None]*len(marker_abscissa)
9301 for value,marker_label in zip(marker_abscissa,marker_labels):
9302 vlinepen = pyqtgraph.mkPen(
9303 color=[int(255 * v) for v in self.cm((j * (self.number_of_datasets) + k) % self.cm_mod)],
9304 width=1)
9305 vline = pyqtgraph.InfiniteLine(
9306 value,pen=vlinepen,hoverPen=pen,
9307 label=marker_label,
9308 labelOpts={'position':0.0,'rotateAxis':(1,0),'anchors':[(0,0),(0,1)],
9309 'color':(0,0,0)})
9310 plot.addItem(vline)
9311 else:
9312 x=data_entry.abscissa
9313 y=complex_fn(data_entry.ordinate) * (180 / np.pi if complex_fn is np.angle else 1)
9314 marker_ordinate = np.interp(marker_abscissa, x, y)
9315 brush = pyqtgraph.mkBrush(
9316 color=[int(255 * v) for v in self.cm((j * (self.number_of_datasets) + k) % self.cm_mod)])
9317 plot.plot(x=marker_abscissa,y=marker_ordinate,pen=None,
9318 symbol=marker_type, symbolSize=self.symbolsize_selector.value(),
9319 symbolPen=pen,symbolBrush=brush)
9320 if marker_labels is not None:
9321 for text_abscissa,text_ordinate,text_label in zip(marker_abscissa,marker_ordinate,marker_labels):
9322 ti = pyqtgraph.TextItem(text_label,anchor=(0,1),color=(0,0,0))
9323 plot.addItem(ti)
9324 # TODO: Remove the log10s when pyqtgraph issue 2166 is fixed
9325 # https://github.com/pyqtgraph/pyqtgraph/issues/2166
9326 ti.setPos(np.log10(text_abscissa) if self.abscissa_log else text_abscissa,
9327 np.log10(text_ordinate) if self.ordinate_log else text_ordinate)
9329 plot.setLogMode(self.abscissa_log,
9330 False if complex_fn is np.angle else self.ordinate_log)
9331 if xrange is not None:
9332 plot.setXRange(*xrange, padding=0.0)
9333 plots.append(plot)
9335 def update_data(self, *data_to_plot, **labeled_data_to_plot):
9336 # Parse the dataset arguments
9337 self._parse_arguments(data_to_plot, labeled_data_to_plot)
9338 # Now go through and reshape the data so it's all the same size
9339 first_data = [v for v in self.data_dictionary.values()][0]
9340 self.data_original_shape = first_data.shape
9341 self.coordinates = first_data.coordinate
9342 for key in self.data_dictionary:
9343 if not np.all(self.coordinates.flatten() == self.data_dictionary[key].coordinate.flatten()):
9344 print('Warning: Coordinates not consistent for dataset {:}'.format(key))
9345 self.data_dictionary[key] = self.data_dictionary[key].flatten()
9346 new_coordinate = first_data.coordinate
9347 if ((self.coordinates.shape != new_coordinate.shape) or
9348 (np.any(self.coordinates != new_coordinate))):
9349 # Redo the table
9350 self.tableWidget.blockSignals(True)
9351 self.tableWidget.clear()
9352 self.tableWidget.setRowCount(0)
9353 for index, fn in first_data.ndenumerate():
9354 this_row = self.tableWidget.rowCount()
9355 self.tableWidget.insertRow(this_row)
9356 try:
9357 self.tableWidget.item(this_row, 0).setText(str(index))
9358 except AttributeError:
9359 item = QtWidgets.QTableWidgetItem(str(index))
9360 self.tableWidget.setItem(this_row, 0, item)
9361 try:
9362 self.tableWidget.item(this_row, 1).setText(str(fn.response_coordinate))
9363 except AttributeError:
9364 item = QtWidgets.QTableWidgetItem(str(fn.response_coordinate))
9365 self.tableWidget.setItem(this_row, 1, item)
9366 if fn.dtype['coordinate'].shape[0] > 1:
9367 try:
9368 self.tableWidget.item(this_row, 2).setText(str(fn.reference_coordinate))
9369 except AttributeError:
9370 item = QtWidgets.QTableWidgetItem(str(fn.reference_coordinate))
9371 self.tableWidget.setItem(this_row, 2, item)
9372 try:
9373 self.tableWidget.item(this_row, 3).setText(
9374 str(_imat_function_type_inverse_map[fn.function_type]))
9375 except AttributeError:
9376 item = QtWidgets.QTableWidgetItem(
9377 str(_imat_function_type_inverse_map[fn.function_type]))
9378 self.tableWidget.setItem(this_row, 3, item)
9379 self.tableWidget.resizeColumnsToContents()
9380 self.tableWidget.blockSignals(False)
9382 # Set up color map
9383 if self.number_of_datasets == 2:
9384 self.cm = cm.tab20
9385 self.cm_mod = 20
9386 elif self.number_of_datasets == 3:
9387 # Combine tab20b and tab20c
9388 tab_b = cm.get_cmap('tab20b', 15)(np.linspace(0, 1, 15))
9389 tab_c = cm.get_cmap('tab20c', 15)(np.linspace(0, 1, 15))
9390 self.cm = ListedColormap(np.concatenate((tab_c, tab_b), axis=0))
9391 self.cm_mod = 30
9392 elif self.number_of_datsets == 4:
9393 # Combine tab20b and tab20c
9394 tab_b = cm.get_cmap('tab20b', 20)(np.linspace(0, 1, 20))
9395 tab_c = cm.get_cmap('tab20c', 20)(np.linspace(0, 1, 20))
9396 self.cm = ListedColormap(np.concatenate((tab_c, tab_b), axis=0))
9397 self.cm_mod = 40
9398 else:
9399 self.cm = cm.tab10
9400 self.cm_mod = 10
9402 self.update()
9404 def selection_changed(self):
9405 """Called when the selected functions is changed"""
9406 if self.autoupdate_checkbox.isChecked():
9407 self.update()
9409 def deselect_all_complex_types_except(self, complex_type):
9410 """
9411 Deselects all complex types except the specified type.
9413 Makes the checkboxes in the menu act like radiobuttons
9415 Parameters
9416 ----------
9417 complex_type : ComplexType
9418 Enumeration specifying which complex plot type is selected
9420 Returns
9421 -------
9422 None.
9424 """
9425 for ct, action in self.complex_types.items():
9426 action.blockSignals(True)
9427 if ct is complex_type:
9428 action.setChecked(True)
9429 else:
9430 action.setChecked(False)
9431 action.blockSignals(False)
9432 if self.autoupdate_checkbox.isChecked():
9433 self.update()
9435 def set_imaginary(self):
9436 """Sets the complex type to imaginary"""
9437 self.deselect_all_complex_types_except(ComplexType.IMAGINARY)
9439 def set_real(self):
9440 """Sets the complex type to real"""
9441 self.deselect_all_complex_types_except(ComplexType.REAL)
9443 def set_magnitude(self):
9444 """Sets the complex type to magnitude"""
9445 self.deselect_all_complex_types_except(ComplexType.MAGNITUDE)
9447 def set_phase(self):
9448 """Sets the complex type to phase"""
9449 self.deselect_all_complex_types_except(ComplexType.PHASE)
9451 def set_magnitude_phase(self):
9452 """Sets the complex type to magnitude and phase"""
9453 self.deselect_all_complex_types_except(ComplexType.MAGPHASE)
9455 def set_real_imag(self):
9456 """Sets the complex type to real and imaginary"""
9457 self.deselect_all_complex_types_except(ComplexType.REALIMAG)
9459 def update_abscissa_log(self):
9460 """Updates whether the abscissa should be plotted as log scale"""
9461 self.abscissa_log = self.actionAbscissa_Log.isChecked()
9462 if self.autoupdate_checkbox.isChecked():
9463 self.update()
9465 def update_ordinate_log(self):
9466 """Updates whether the ordinate should be plotted as log scale"""
9467 self.ordinate_log = self.actionOrdinate_Log.isChecked()
9468 if self.autoupdate_checkbox.isChecked():
9469 self.update()
9471 def update_checkbox(self):
9472 """Disables the update button if set to auto-update"""
9473 self.pushButton.setEnabled(not self.autoupdate_checkbox.isChecked())
9476class MPLCanvas(FigureCanvas):
9477 # This is a custom widget that can be used to put plots into a GUI window
9478 def __init__(self, parent=None, width=5, height=4, dpi=100):
9479 fig = Figure(figsize=(width, height), dpi=dpi)
9480 self.axis = fig.add_subplot(111)
9482 FigureCanvas.__init__(self, fig)
9483 self.setParent(parent)
9485 FigureCanvas.setSizePolicy(self, QSizePolicy.Expanding, QSizePolicy.Expanding)
9486 FigureCanvas.updateGeometry(self)
9489class MPLMultiCanvas(FigureCanvas):
9490 # This is a custom widget that can be used to put plots into a GUI window
9491 def __init__(self, parent=None, width=5, height=4, dpi=100, subplots=(1, 1), ignore_subplots=[]):
9492 self.fig = Figure(figsize=(width, height), dpi=dpi)
9493 self.axes = np.empty(subplots, dtype=object)
9494 for i in range(subplots[0]):
9495 for j in range(subplots[1]):
9496 index = i * subplots[1] + j + 1
9497 if index in ignore_subplots:
9498 self.axes[i, j] = None
9499 else:
9500 self.axes[i, j] = self.fig.add_subplot(subplots[0], subplots[1], index)
9502 FigureCanvas.__init__(self, self.fig)
9503 self.setParent(parent)
9505 FigureCanvas.setSizePolicy(self, QSizePolicy.Expanding, QSizePolicy.Expanding)
9506 FigureCanvas.updateGeometry(self)
9509class CPSDPlot(QMainWindow):
9511 class DataType(Enum):
9512 MAGNITUDE = 1
9513 COHERENCE = 2
9514 PHASE = 4
9515 REAL = 8
9516 IMAGINARY = 16
9518 def __init__(self, data: PowerSpectralDensityArray,
9519 compare_data: PowerSpectralDensityArray = None):
9520 if not data.validate_common_abscissa(rtol=1, atol=1e-8):
9521 raise ValueError('Data must have common abscissa')
9522 if compare_data is not None:
9523 if data.ordinate.shape != compare_data.ordinate.shape:
9524 raise ValueError('compare_data.ordinate must have the same size as data.ordinate ({:} != {:})'.format(
9525 compare_data.ordinate.shape, data.ordinate.shape))
9526 super().__init__()
9527 self.abscissa = data.flatten()[0].abscissa
9528 self.matrix = np.moveaxis(data.reshape_to_matrix().ordinate, -1, 0)
9529 self.compare_matrix = None if compare_data is None else np.moveaxis(
9530 compare_data.reshape_to_matrix().ordinate, -1, 0)
9531 self.cm = cm.Dark2 if compare_data is None else cm.Paired
9532 self.coh_matrix = sp_coherence(self.matrix)
9533 self.compare_coh_matrix = None if compare_data is None else sp_coherence(
9534 self.compare_matrix)
9535 self.selected_function = None
9536 self.plotted_function = None
9537 self.plot_type_bits = None
9538 self.select_start = None
9539 self.select_rectangles = None
9540 self.plot_rectangles = None
9541 self.initUI()
9542 self.connectUI()
9543 self.show()
9544 self.init_matrix_plot()
9546 def initUI(self):
9547 # Main Widget
9548 self.main_widget = QWidget(self)
9549 self.main_layout = QVBoxLayout(self.main_widget)
9550 # Figure Control Groupbox
9551 self.fig_control_groupbox = QGroupBox(self.main_widget)
9552 self.fig_control_groupbox_layout = QVBoxLayout(self.fig_control_groupbox)
9553 # Figure Selection Group Box
9554 self.function_select_groupbox = QGroupBox(self.fig_control_groupbox)
9555 self.function_select_groupbox_layout = QVBoxLayout()
9556 self.function_select_groupbox.setLayout(self.function_select_groupbox_layout)
9557 self.fig_control_groupbox_layout.addWidget(self.function_select_groupbox)
9558 self.selection_options_groupbox = QGroupBox(self.fig_control_groupbox)
9559 self.selection_options_groupbox_layout = QVBoxLayout(self.selection_options_groupbox)
9560 # Grid Layout for Selection Buttons
9561 self.selection_options_button_layout = QGridLayout()
9562 self.diagonal_select_button = QPushButton(self.selection_options_groupbox)
9563 self.selection_options_button_layout.addWidget(self.diagonal_select_button, 0, 0, 1, 1)
9564 self.clear_selection_button = QPushButton(self.selection_options_groupbox)
9565 self.selection_options_button_layout.addWidget(self.clear_selection_button, 2, 2, 1, 1)
9566 self.upper_tri_select_button = QPushButton(self.selection_options_groupbox)
9567 self.selection_options_button_layout.addWidget(self.upper_tri_select_button, 0, 1, 1, 1)
9568 self.invert_select_button = QPushButton(self.selection_options_groupbox)
9569 self.selection_options_button_layout.addWidget(self.invert_select_button, 2, 1, 1, 1)
9570 self.plotted_select_button = QPushButton(self.selection_options_groupbox)
9571 self.selection_options_button_layout.addWidget(self.plotted_select_button, 2, 0, 1, 1)
9572 self.lower_tri_select_button = QPushButton(self.selection_options_groupbox)
9573 self.selection_options_button_layout.addWidget(self.lower_tri_select_button, 0, 2, 1, 1)
9574 self.diagonal_deselect_button = QPushButton(self.selection_options_groupbox)
9575 self.selection_options_button_layout.addWidget(self.diagonal_deselect_button, 1, 0, 1, 1)
9576 self.upper_tri_deselect_button = QPushButton(self.selection_options_groupbox)
9577 self.selection_options_button_layout.addWidget(self.upper_tri_deselect_button, 1, 1, 1, 1)
9578 self.lower_tri_deselect_button = QPushButton(self.selection_options_groupbox)
9579 self.selection_options_button_layout.addWidget(self.lower_tri_deselect_button, 1, 2, 1, 1)
9580 self.selection_options_groupbox_layout.addLayout(self.selection_options_button_layout)
9581 self.matrix_select_checkbox = QCheckBox(self.selection_options_groupbox)
9582 self.selection_options_groupbox_layout.addWidget(self.matrix_select_checkbox)
9583 self.fig_control_groupbox_layout.addWidget(self.selection_options_groupbox)
9584 # Plotting Options Groupbox
9585 self.plotting_options_groupbox = QGroupBox(self.fig_control_groupbox)
9586 self.plotting_options_groupbox_layout = QVBoxLayout(self.plotting_options_groupbox)
9587 # Grid layout for plot buttons
9588 self.plotting_layout = QGridLayout()
9589 self.plot_selected_button = QPushButton(self.plotting_options_groupbox)
9590 self.plotting_layout.addWidget(self.plot_selected_button, 0, 0, 1, 1)
9591 self.plotting_mode_layout = QVBoxLayout()
9592 # Radio Buttons for plotting method
9593 self.matrix_mode_button = QRadioButton(self.plotting_options_groupbox)
9594 self.plotting_mode_layout.addWidget(self.matrix_mode_button)
9595 self.matrix_mode_button.setChecked(True)
9596 self.sequential_mode_button = QRadioButton(self.plotting_options_groupbox)
9597 self.plotting_mode_layout.addWidget(self.sequential_mode_button)
9598 self.plotting_layout.addLayout(self.plotting_mode_layout, 0, 1, 1, 1)
9599 self.plotting_options_groupbox_layout.addLayout(self.plotting_layout)
9600 # Grid Layout for the function types
9601 self.function_type_layout = QGridLayout()
9602 self.phase_checkbox = QCheckBox(self.plotting_options_groupbox)
9603 self.function_type_layout.addWidget(self.phase_checkbox, 0, 2, 1, 1)
9604 self.magnitude_checkbox = QCheckBox(self.plotting_options_groupbox)
9605 self.function_type_layout.addWidget(self.magnitude_checkbox, 0, 0, 1, 1)
9606 self.coherence_checkbox = QCheckBox(self.plotting_options_groupbox)
9607 self.function_type_layout.addWidget(self.coherence_checkbox, 0, 1, 1, 1)
9608 self.real_checkbox = QCheckBox(self.plotting_options_groupbox)
9609 self.function_type_layout.addWidget(self.real_checkbox, 1, 0, 1, 1)
9610 self.imaginary_checkbox = QCheckBox(self.plotting_options_groupbox)
9611 self.function_type_layout.addWidget(self.imaginary_checkbox, 1, 1, 1, 1)
9612 self.function_type_checkboxes = [self.magnitude_checkbox,
9613 self.coherence_checkbox,
9614 self.phase_checkbox,
9615 self.real_checkbox,
9616 self.imaginary_checkbox]
9617 # Set them so they are tristate
9618 for box in [self.phase_checkbox, self.magnitude_checkbox, self.coherence_checkbox,
9619 self.real_checkbox, self.imaginary_checkbox]:
9620 box.setTristate(True)
9621 self.plotting_options_groupbox_layout.addLayout(self.function_type_layout)
9622 self.fig_control_groupbox_layout.addWidget(self.plotting_options_groupbox)
9623 # Finish setting up the main window
9624 self.main_layout.addWidget(self.fig_control_groupbox)
9625 self.setCentralWidget(self.main_widget)
9626 # Set up the menu bar
9627 # self.menubar = QMenuBar(self)
9628 # self.menubar.setGeometry(QRect(0, 0, 742, 21))
9629 # self.menubar.setAccessibleName("")
9630 # self.menuFile = QMenu(self.menubar)
9631 # self.menuFigure = QMenu(self.menubar)
9632 # self.setMenuBar(self.menubar)
9633 self.functions_dock = QDockWidget(self)
9634 self.functions_dock.setFeatures(QDockWidget.DockWidgetFloatable |
9635 QDockWidget.DockWidgetMovable)
9636 self.functions_dock_main = QWidget()
9637 self.functions_dock_main_layout = QVBoxLayout(self.functions_dock_main)
9638 self.functions_groupbox = QGroupBox(self.functions_dock_main)
9639 self.functions_groupbox_layout = QVBoxLayout()
9640 self.functions_groupbox.setLayout(self.functions_groupbox_layout)
9641 self.functions_dock_main_layout.addWidget(self.functions_groupbox)
9642 self.functions_dock.setWidget(self.functions_dock_main)
9643 self.addDockWidget(Qt.DockWidgetArea(1), self.functions_dock)
9645 # self.actionLoad_Matrix = QAction(self)
9646 # self.actionLoad_Matrix.setObjectName("actionLoad_Matrix")
9647 # self.actionExit = QAction(self)
9648 # self.actionExit.setObjectName("actionExit")
9649 # self.actionSave_Figure = QAction(self)
9650 # self.actionSave_Figure.setObjectName("actionSave_Figure")
9651 # self.menuFile.addAction(self.actionLoad_Matrix)
9652 # self.menuFile.addSeparator()
9653 # self.menuFile.addAction(self.actionExit)
9654 # self.menuFigure.addAction(self.actionSave_Figure)
9655 # self.menubar.addAction(self.menuFile.menuAction())
9656 # self.menubar.addAction(self.menuFigure.menuAction())
9658 self.settext()
9660 def settext(self):
9661 self.setWindowTitle('Cross-power Spectral Density Matrix Viewer')
9662 self.fig_control_groupbox.setTitle("Figure Control")
9663 self.function_select_groupbox.setTitle("Function Select")
9664 self.selection_options_groupbox.setTitle("Selection Options")
9665 self.diagonal_select_button.setText("Select Diagonal")
9666 self.clear_selection_button.setText("Clear Selection")
9667 self.upper_tri_select_button.setText("Select Upper Triangle")
9668 self.invert_select_button.setText("Invert Selection")
9669 self.plotted_select_button.setText("Select Plotted")
9670 self.lower_tri_select_button.setText("Select Lower Triangle")
9671 self.diagonal_deselect_button.setText("Deselect Diagonal")
9672 self.upper_tri_deselect_button.setText("Deselect Upper Triangle")
9673 self.lower_tri_deselect_button.setText("Deselect Lower Triangle")
9674 self.matrix_select_checkbox.setText("Matrix Select Mode")
9675 self.plotting_options_groupbox.setTitle("Plotting Options")
9676 self.plot_selected_button.setText("Plot Selected")
9677 self.matrix_mode_button.setText("Matrix Mode")
9678 self.sequential_mode_button.setText("Sequential Mode")
9679 self.phase_checkbox.setText("Phase")
9680 self.magnitude_checkbox.setText("Magnitude")
9681 self.coherence_checkbox.setText("Coherence")
9682 self.real_checkbox.setText("Real")
9683 self.imaginary_checkbox.setText("Imaginary")
9684 # self.menuFile.setTitle("File")
9685 # self.menuFigure.setTitle("Figure")
9686 self.functions_groupbox.setTitle("Functions")
9687 # self.actionLoad_Matrix.setText("Load Matrix...")
9688 # self.actionExit.setText("Exit")
9689 # self.actionSave_Figure.setText("Save Figure...")
9691 def connectUI(self):
9692 # self.actionLoad_Matrix.triggered.connect(self.load)
9693 # self.actionExit.triggered.connect(self.quit)
9694 # Selection Method checkbox
9695 self.matrix_select_checkbox.stateChanged.connect(self.state_changed)
9696 # Plot type checkboxes
9697 self.magnitude_checkbox.stateChanged.connect(self.magnitude_state)
9698 self.coherence_checkbox.stateChanged.connect(self.coherence_state)
9699 self.phase_checkbox.stateChanged.connect(self.phase_state)
9700 self.real_checkbox.stateChanged.connect(self.real_state)
9701 self.imaginary_checkbox.stateChanged.connect(self.imaginary_state)
9702 # Selection Buttons
9703 self.diagonal_select_button.clicked.connect(self.select_diagonal)
9704 self.diagonal_deselect_button.clicked.connect(self.deselect_diagonal)
9705 self.upper_tri_select_button.clicked.connect(self.select_upper_triangular)
9706 self.upper_tri_deselect_button.clicked.connect(self.deselect_upper_triangular)
9707 self.lower_tri_select_button.clicked.connect(self.select_lower_triangular)
9708 self.lower_tri_deselect_button.clicked.connect(self.deselect_lower_triangular)
9709 self.clear_selection_button.clicked.connect(self.clear_selection)
9710 self.invert_select_button.clicked.connect(self.invert_selection)
9711 self.plotted_select_button.clicked.connect(self.select_plotted)
9712 # Plot Button
9713 self.plot_selected_button.clicked.connect(self.plot_selected_function)
9715 def init_matrix_plot(self):
9716 # Create the matrix
9717 shape = self.matrix.shape
9718 max_freq_lines = 50
9719 freq_decimate_factor = int(np.ceil(shape[0] / max_freq_lines))
9720 indices = np.zeros(shape[0], dtype=bool)
9721 indices[freq_decimate_factor // 2::freq_decimate_factor] = True
9722 self.selector = MPLCanvas(self, width=2, height=2, dpi=100)
9723 self.selector.setFocusPolicy(Qt.ClickFocus)
9724 self.selector.setFocus()
9725 self.selector.mpl_connect('button_press_event', self.selector_click)
9726 self.selector.mpl_connect('button_release_event', self.selector_unclick)
9727 self.selector.axis.plot([shape[2], shape[1]], [0, shape[1]], 'k', linewidth=0.25)
9728 self.selector.axis.plot([0, shape[2]], [shape[1], shape[1]], 'k', linewidth=0.25)
9729 self.selector.axis.plot([0, shape[2]], [0, shape[1]], 'k', linewidth=0.25)
9730 self.select_rectangles = np.empty(shape[1:], dtype=object)
9731 self.plot_rectangles = np.empty(shape[1:], dtype=object)
9732 self.function_select_groupbox_layout.addWidget(self.selector)
9733 for i in range(shape[1]):
9734 self.selector.axis.plot([0, shape[2]], [i, i], 'k', linewidth=0.25)
9735 for j in range(shape[2]):
9736 if i == 0:
9737 self.selector.axis.plot([j, j], [0, shape[1]], 'k', linewidth=0.25)
9738 data = np.log10(abs(self.matrix[:, i, j]))
9739 data -= np.mean(data)
9740 data /= -2.5 * np.max(data)
9741 abscissa = np.linspace(j, j + 1, shape[0])
9742 logical_selector = np.logical_and(np.logical_and(data > -.5, data < .5), indices)
9743 self.selector.axis.plot(abscissa[logical_selector],
9744 data[logical_selector] + i + .5, 'b', linewidth=.25)
9745 # Create the rectangle
9746 self.select_rectangles[i, j] = self.selector.axis.add_patch(
9747 Rectangle((j, i), 1, 1, alpha=0.5, color='k', visible=False))
9748 self.plot_rectangles[i, j] = self.selector.axis.add_patch(
9749 Rectangle((j, i), 1, 1, alpha=0.5, color='r', visible=False))
9751 self.selector.axis.set_position([0, 0, 1, 1])
9752 self.selector.axis.set_xlim(0, shape[2])
9753 self.selector.axis.set_ylim(0, shape[1])
9754 self.selector.axis.set_xticks([])
9755 self.selector.axis.set_yticks([])
9756 self.selector.axis.invert_yaxis()
9757 self.selected_function = np.zeros(shape[1:], dtype=bool)
9758 self.plotted_function = np.zeros(shape[1:], dtype=bool)
9759 self.plot_type_bits = np.zeros(shape[1:], dtype='int8')
9760# # Get the current widget size and resize if necessary
9761# self.selector.updateGeometry()
9762# w = self.selector.width()
9763# h = self.selector.height()
9764# print((w,h))
9765# self.selector.resize(shape[2]*10 if w < shape[2]*10 else w,
9766# shape[1]*10 if h < shape[1]*10 else h)
9767# self.selector.updateGeometry()
9769 def selector_click(self, event):
9770 # print('{:} Click: button={:}, x={:}, y={:}, xdata={:}, ydata={:}'.format(
9771 # 'double' if event.dblclick else 'single',
9772 # event.button, event.x,event.y,event.xdata,event.ydata))
9773 j = int(np.floor(event.xdata))
9774 i = int(np.floor(event.ydata))
9775 self.select_start = (i, j)
9777 def selector_unclick(self, event):
9778 modifiers = QApplication.keyboardModifiers()
9779 # print('{:} Release: button={:}, x={:}, y={:}, xdata={:}, ydata={:}'.format(
9780 # 'double' if event.dblclick else 'single',
9781 # event.button, event.x,event.y,event.xdata,event.ydata))
9782 j = int(np.floor(event.xdata))
9783 i = int(np.floor(event.ydata))
9784 try:
9785 ii, ji = self.select_start
9786 except TypeError:
9787 self.select_start = None
9788 return
9789 # See if we need to switch them
9790 if i < ii:
9791 i, ii = ii, i
9792 if j < ji:
9793 j, ji = ji, j
9794 if modifiers == Qt.ShiftModifier:
9795 self.selected_function[ii:i + 1, ji:j + 1] = True
9796 elif modifiers == Qt.ControlModifier and ii == i and ji == j:
9797 if self.matrix_select_checkbox.isChecked() and self.selected_function[i, j]:
9798 self.selected_function[i, :] = False
9799 self.selected_function[:, j] = False
9800 else:
9801 self.selected_function[i, j] = not self.selected_function[i, j]
9802 else:
9803 self.selected_function[:] = False
9804 self.selected_function[ii:i + 1, ji:j + 1] = True
9805 if self.matrix_select_checkbox.isChecked():
9806 self.extend_selection_matrix()
9807 self.update_selection()
9809 def select_upper_triangular(self, event):
9810 self.matrix_select_checkbox.setChecked(False)
9811 for (i, j), val in np.ndenumerate(self.selected_function):
9812 if i < j:
9813 self.selected_function[i, j] = True
9814 self.update_selection()
9816 def select_lower_triangular(self, event):
9817 self.matrix_select_checkbox.setChecked(False)
9818 for (i, j), val in np.ndenumerate(self.selected_function):
9819 if i > j:
9820 self.selected_function[i, j] = True
9821 self.update_selection()
9823 def select_diagonal(self, event):
9824 self.matrix_select_checkbox.setChecked(False)
9825 for (i, j), val in np.ndenumerate(self.selected_function):
9826 if i == j:
9827 self.selected_function[i, j] = True
9828 self.update_selection()
9830 def deselect_upper_triangular(self, event):
9831 self.matrix_select_checkbox.setChecked(False)
9832 for (i, j), val in np.ndenumerate(self.selected_function):
9833 if i < j:
9834 self.selected_function[i, j] = False
9835 self.update_selection()
9837 def deselect_lower_triangular(self, event):
9838 self.matrix_select_checkbox.setChecked(False)
9839 for (i, j), val in np.ndenumerate(self.selected_function):
9840 if i > j:
9841 self.selected_function[i, j] = False
9842 self.update_selection()
9844 def deselect_diagonal(self, event):
9845 self.matrix_select_checkbox.setChecked(False)
9846 for (i, j), val in np.ndenumerate(self.selected_function):
9847 if i == j:
9848 self.selected_function[i, j] = False
9849 self.update_selection()
9851 def clear_selection(self, event):
9852 self.selected_function[:] = False
9853 self.update_selection()
9855 def invert_selection(self, event):
9856 self.matrix_select_checkbox.setChecked(False)
9857 for (i, j), val in np.ndenumerate(self.selected_function):
9858 self.selected_function[i, j] = not self.selected_function[i, j]
9859 self.update_selection()
9861 def select_plotted(self, event):
9862 self.matrix_select_checkbox.setChecked(False)
9863 self.selected_function[:] = self.plotted_function[:]
9864 self.update_selection()
9866 def magnitude_state(self, event):
9867 # print('Magnitude Checkbox Fired')
9868 # print(self.magnitude_checkbox.checkState())
9869 if self.magnitude_checkbox.checkState() > 0:
9870 self.plot_type_bits[self.selected_function] |= self.DataType.MAGNITUDE.value
9871 self.magnitude_checkbox.blockSignals(True)
9872 self.magnitude_checkbox.setCheckState(2)
9873 self.magnitude_checkbox.blockSignals(False)
9874 else:
9875 self.plot_type_bits[self.selected_function] &= ~self.DataType.MAGNITUDE.value
9877 def coherence_state(self, event):
9878 # print('Coherence Checkbox Fired')
9879 if self.coherence_checkbox.checkState() > 0:
9880 self.plot_type_bits[self.selected_function] |= self.DataType.COHERENCE.value
9881 self.coherence_checkbox.blockSignals(True)
9882 self.coherence_checkbox.setCheckState(2)
9883 self.coherence_checkbox.blockSignals(False)
9884 else:
9885 self.plot_type_bits[self.selected_function] &= ~self.DataType.COHERENCE.value
9887 def phase_state(self, event):
9888 # print('Phase Checkbox Fired')
9889 if self.phase_checkbox.checkState() > 0:
9890 self.plot_type_bits[self.selected_function] |= self.DataType.PHASE.value
9891 self.phase_checkbox.blockSignals(True)
9892 self.phase_checkbox.setCheckState(2)
9893 self.phase_checkbox.blockSignals(False)
9894 else:
9895 self.plot_type_bits[self.selected_function] &= ~self.DataType.PHASE.value
9897 def real_state(self, event):
9898 # print('Real Checkbox Fired')
9899 if self.real_checkbox.checkState() > 0:
9900 self.plot_type_bits[self.selected_function] |= self.DataType.REAL.value
9901 self.real_checkbox.blockSignals(True)
9902 self.real_checkbox.setCheckState(2)
9903 self.real_checkbox.blockSignals(False)
9904 else:
9905 self.plot_type_bits[self.selected_function] &= ~self.DataType.REAL.value
9907 def imaginary_state(self, event):
9908 # print('Imaginary Checkbox Fired')
9909 if self.imaginary_checkbox.checkState() > 0:
9910 self.plot_type_bits[self.selected_function] |= self.DataType.IMAGINARY.value
9911 self.imaginary_checkbox.blockSignals(True)
9912 self.imaginary_checkbox.setCheckState(2)
9913 self.imaginary_checkbox.blockSignals(False)
9914 else:
9915 self.plot_type_bits[self.selected_function] &= ~self.DataType.IMAGINARY.value
9917 def plot_selected_function(self, event):
9918 self.plotted_function[:] = False
9919 self.plotted_function[self.selected_function] = True
9920 for key, val in np.ndenumerate(self.plotted_function):
9921 # print((key,val))
9922 if val:
9923 self.plot_rectangles[key].set_visible(True)
9924 else:
9925 self.plot_rectangles[key].set_visible(False)
9926 self.selector.draw()
9927 self.plot()
9929 def update_selection(self):
9930 # print('updating selection')
9931 # Update the function type check boxes
9932 out = self.find_function_types()
9933 for val, checkbox in zip(out, self.function_type_checkboxes):
9934 checkbox.blockSignals(True)
9935 checkbox.setCheckState(int(val))
9936 checkbox.blockSignals(False)
9937 for key, val in np.ndenumerate(self.selected_function):
9938 # print((key,val))
9939 if val:
9940 self.select_rectangles[key].set_visible(True)
9941 else:
9942 self.select_rectangles[key].set_visible(False)
9943 # print('drawing')
9944 self.selector.draw()
9946 def extend_selection_matrix(self):
9947 rows = np.any(self.selected_function, axis=1)
9948 cols = np.any(self.selected_function, axis=0)
9949 self.selected_function = rows[:, np.newaxis] * cols[np.newaxis, :]
9951 def state_changed(self, event):
9952 if self.matrix_select_checkbox.isChecked():
9953 self.extend_selection_matrix()
9954 self.update_selection()
9955 self.selector.setFocus()
9957 def find_function_types(self):
9958 # print(self.plot_type_bits.shape)
9959 # print(self.selected_function.shape)
9960 mat = self.plot_type_bits[self.selected_function]
9961 # print(mat)
9962 any_functions = [1 if np.any(mat & bit) else 0 for bit in [self.DataType.MAGNITUDE.value, self.DataType.COHERENCE.value,
9963 self.DataType.PHASE.value, self.DataType.REAL.value, self.DataType.IMAGINARY.value]]
9964 all_functions = [2 if np.all(mat & bit) else 0 for bit in [self.DataType.MAGNITUDE.value, self.DataType.COHERENCE.value,
9965 self.DataType.PHASE.value, self.DataType.REAL.value, self.DataType.IMAGINARY.value]]
9966 return np.max([any_functions, all_functions], axis=0)
9968 def plot(self):
9969 # print('plotting')
9970 # Create the figure and put it in the dock
9971 functions = self.matrix[:, self.plotted_function]
9972 coherence_functions = self.coh_matrix[:, self.plotted_function]
9973 if self.compare_matrix is not None:
9974 compare_functions = self.compare_matrix[:, self.plotted_function]
9975 compare_coherence_functions = self.compare_coh_matrix[:, self.plotted_function]
9976 cm_increment = 2
9977 else:
9978 cm_increment = 1
9979 plot_bits = self.plot_type_bits[self.plotted_function]
9980 if self.matrix_mode_button.isChecked():
9981 rows = np.nonzero(np.any(self.plotted_function, axis=1))[0]
9982 cols = np.nonzero(np.any(self.plotted_function, axis=0))[0]
9983 indices_to_skip = []
9984 index = 1
9985 for row in rows:
9986 for col in cols:
9987 # print((row,col))
9988 # print(self.plotted_function.shape)
9989 # print(self.plotted_function[row,col])
9990 if not self.plotted_function[row, col]:
9991 indices_to_skip.append(index)
9992 index += 1
9993 nrows = len(rows)
9994 ncols = len(cols)
9995 else:
9996 square_array_size = np.ceil(np.sqrt(functions.shape[1]))
9997 nrows = int(square_array_size)
9998 ncols = int(np.ceil(functions.shape[1] / nrows))
9999 indices_to_skip = list(range(functions.shape[1] + 1, nrows * ncols + 1))
10000 # Create the canvas
10001 try:
10002 # Delete the canvas if it exists
10003 self.functions_groupbox_layout.removeWidget(self.plot_canvas)
10004 self.plot_canvas.deleteLater()
10005 except AttributeError:
10006 # print('No Widget to Remove')
10007 pass
10008 self.plot_canvas = MPLMultiCanvas(
10009 self, subplots=[nrows, ncols], ignore_subplots=indices_to_skip)
10010 self.functions_groupbox_layout.addWidget(self.plot_canvas)
10011 index = 0
10012 for key, axis in np.ndenumerate(self.plot_canvas.axes):
10013 if axis is None:
10014 continue
10015 function = functions[:, index]
10016 coh_function = coherence_functions[:, index]
10017 if self.compare_matrix is not None:
10018 compare_function = compare_functions[:, index]
10019 compare_coh_function = compare_coherence_functions[:, index]
10020 else:
10021 compare_function = np.nan
10022 compare_coh_function = np.nan
10023 bits = [val for val in [self.DataType.MAGNITUDE.value, self.DataType.COHERENCE.value, self.DataType.PHASE.value,
10024 self.DataType.REAL.value, self.DataType.IMAGINARY.value] if bool(plot_bits[index] & val)]
10025 plots = []
10026 for bit in bits:
10027 if bit == self.DataType.MAGNITUDE.value:
10028 plots.append((abs(function), abs(compare_function), 0, 'log'))
10029 elif bit == self.DataType.COHERENCE.value:
10030 plots.append((coh_function, compare_coh_function, 1, 'linear'))
10031 elif bit == self.DataType.PHASE.value:
10032 plots.append((np.angle(function), np.angle(compare_function), 2, 'linear'))
10033 elif bit == self.DataType.REAL.value:
10034 plots.append((np.real(function), np.real(compare_function), 3, 'linear'))
10035 elif bit == self.DataType.IMAGINARY.value:
10036 plots.append((np.imag(function), np.imag(compare_function), 4, 'linear'))
10037 # Add a y axis for each plot
10038 this_ax = [axis] + [axis.twinx() for p in plots[1:]]
10039 for (fn, compare_fn, color_index, scale), ax in zip(plots, this_ax):
10040 color = self.cm(color_index * cm_increment)
10041 ax.plot(self.abscissa, fn, color=color)
10042 if self.compare_matrix is not None:
10043 compare_color = self.cm(color_index * cm_increment + 1)
10044 ax.plot(self.abscissa, compare_fn, color=compare_color)
10045 ax.set_yscale(scale)
10046 ax.tick_params(axis='y', colors=color)
10047 index += 1
10048# self.plot_canvas.fig.tight_layout()
10051frf_from_time_data = TransferFunctionArray.from_time_data
10052join = NDDataArray.join