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

1# -*- coding: utf-8 -*- 

2""" 

3Defines the NDDataArray, which defines function data such as time histories. 

4 

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. 

14 

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. 

19 

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. 

24 

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""" 

28 

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) 

56 

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') 

95 

96 

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 

119 

120 

121_specific_data_names = {val: val.name.replace('_', ' ').title() for val in SpecificDataType} 

122 

123_specific_data_names_vectorized = np.vectorize(_specific_data_names.__getitem__) 

124 

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# -------------------------------------------------------- 

150 

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} 

169 

170_exponent_table_vectorized = np.vectorize(_exponent_table.__getitem__) 

171 

172 

173class TypeQual(Enum): 

174 """Enumeration containing the quantity type (Rotation or Translation)""" 

175 TRANSLATION = 0 

176 ROTATION = 1 

177 

178 

179_type_qual_names = {val: val.name.replace('_', ' ').title() for val in TypeQual} 

180_type_qual_names_vectorized = np.vectorize(_type_qual_names.__getitem__) 

181 

182 

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 

215 

216 

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 } 

248 

249_imat_function_type_inverse_map = {val: key for key, val in _imat_function_type_map.items()} 

250 

251 

252def _flat_frequency_shape(freq): 

253 return 1 

254 

255 

256class AbscissaIndexExtractor: 

257 def __init__(self, parent): 

258 self.parent = parent 

259 

260 def __getitem__(self, key): 

261 return self.parent.extract_elements(key) 

262 

263 def __call__(self, key): 

264 return self.parent.extract_elements(key) 

265 

266 

267class AbscissaValueExtractor: 

268 def __init__(self, parent): 

269 self.parent = parent 

270 

271 def __getitem__(self, key): 

272 return self.parent.extract_elements_by_abscissa(key[0], key[1]) 

273 

274 def __call__(self, key): 

275 return self.parent.extract_elements_by_abscissa(key[0], key[1]) 

276 

277 

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 

283 

284class NDDataArray(SdynpyArray): 

285 """Generic N-Dimensional data structure 

286 

287 This data structure can contain real or complex data. More specific 

288 SDynPy data arrays inherit from this superclass. 

289 """ 

290 

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 

313 

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 

320 

321 @property 

322 def response_coordinate(self): 

323 """CoordinateArray corresponding to the response coordinates""" 

324 return self.coordinate[..., 0] 

325 

326 @response_coordinate.setter 

327 def response_coordinate(self, value): 

328 """Set the response coordinate of the data array""" 

329 self.coordinate[..., 0] = value 

330 

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] 

337 

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 

344 

345 @property 

346 def num_elements(self): 

347 """Number of elements in each data array""" 

348 return self.dtype['ordinate'].shape[0] 

349 

350 @property 

351 def num_coordinates(self): 

352 """Number of coordinates defining the data array""" 

353 return self.dtype['coordinate'].shape[0] 

354 

355 @property 

356 def data_dimension(self): 

357 """Number of dimensions to the data""" 

358 return self.dtype['coordinate'].shape[-1] 

359 

360 @property 

361 def idx_by_el(self): 

362 """ 

363 AbscissaIndexExtractor that can be indexed to extract specific elements 

364 """ 

365 return AbscissaIndexExtractor(self) 

366 

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) 

373 

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 

384 

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 

391 

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. 

418 

419 Returns 

420 ------- 

421 axis : matplotlib axis or array of axes 

422 On which the data were plotted 

423 

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)] 

431 

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 

502 

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. 

507 

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. 

524 

525 Returns 

526 ------- 

527 GUIPlot 

528 

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) 

543 

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 

578 

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 

583 

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. 

592 

593 Returns 

594 ------- 

595 output_array : Data Aarray 

596 2D Array of NDDataArray 

597 

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 

631 

632 def extract_elements(self, indices): 

633 """ 

634 Parses elements from the data array specified by the passed indices 

635 

636 Parameters 

637 ---------- 

638 indices : 

639 Any type of indices into a np.ndarray to select the elements to keep 

640 

641 Returns 

642 ------- 

643 NDDataArray 

644 Array reduced to specified elements 

645 

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) 

651 

652 def extract_elements_by_abscissa(self, min_abscissa, max_abscissa): 

653 """ 

654 Extracts elements with abscissa values within the specified range 

655 

656 Parameters 

657 ---------- 

658 min_abscissa : float 

659 Minimum abscissa value to keep 

660 max_abscissa : float 

661 Maximum abscissa value to keep. 

662 

663 Returns 

664 ------- 

665 NDDataArray 

666 Array reduced to specified elements. 

667 

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) 

675 

676 @classmethod 

677 def join(cls, data_arrays, increment_abscissa=True): 

678 """ 

679 Joins several data arrays together by concatenating their ordinates 

680 

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. 

689 

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) 

707 

708 def downsample(self, factor): 

709 """ 

710 Downsample a signal by keeping only every n-th abscissa/ordinate pair. 

711 

712 Parameters 

713 ---------- 

714 factor : int 

715 Downsample factor. Only the factor-th abcissa will be kept. 

716 

717 Returns 

718 ------- 

719 NDDataArray 

720 The downsampled data object 

721 

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) 

727 

728 def validate_common_abscissa(self, **allclose_kwargs): 

729 """ 

730 Returns True if all functions have the same abscissa 

731 

732 Parameters 

733 ---------- 

734 **allclose_kwargs : various 

735 Arguments to np.allclose to specify tolerances 

736 

737 Returns 

738 ------- 

739 bool 

740 True if all functions have the same abscissa 

741 

742 """ 

743 return np.allclose(self.flatten()[0].abscissa, self.abscissa, **allclose_kwargs) 

744 

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 

748 

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. 

763 

764 Returns 

765 ------- 

766 NDDataArray or Subclass 

767 A NDDataArray that can now be plotted with the new geometry 

768 

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') 

790 

791 def to_shape_array(self, abscissa_values=None): 

792 """ 

793 Converts an NDDataArray to a ShapeArray 

794 

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 

802 

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 

808 

809 Returns 

810 ------- 

811 ShapeArray 

812 ShapeArray containing the NDDataArray's ordinate as its shape_matrix 

813 

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]) 

831 

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 

837 

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`. 

860 

861 Returns 

862 ------- 

863 NDDataArray subclass 

864 The zero-padded version of the function 

865 

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,)) 

885 

886 new_ordinate = np.concatenate((added_zeros_left, self.ordinate, added_zeros_right), axis=-1) 

887 

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) 

892 

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) 

896 

897 def interpolate(self, interpolated_abscissa, kind='linear', **kwargs): 

898 """ 

899 Interpolates the NDDataArray using SciPy's interp1d. 

900 

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. 

920 

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 

966 

967 def __getitem__(self, key): 

968 """ 

969 Selects specific data items by index or by coordinate 

970 

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. 

977 

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 

1016 

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) 

1019 

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 

1031 

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 

1043 

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 

1055 

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 

1150 

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 

1262 

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 

1274 

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 

1286 

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 

1298 

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 

1310 

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 

1322 

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 

1334 

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 

1346 

1347 def __neg__(self): 

1348 this = deepcopy(self) 

1349 this.ordinate *= -1 

1350 return this 

1351 

1352 def __abs__(self): 

1353 this = deepcopy(self) 

1354 this.ordinate = np.abs(this.ordinate) 

1355 return this 

1356 

1357 def min(self, reduction=None, *min_args, **min_kwargs): 

1358 """ 

1359 Returns the minimum ordinate in the data array 

1360 

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 

1370 

1371 Returns 

1372 ------- 

1373 Value 

1374 Minimum value in the ordinate. 

1375 

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) 

1381 

1382 def max(self, reduction=None, *max_args, **max_kwargs): 

1383 """ 

1384 Returns the maximum ordinate in the data array 

1385 

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 

1395 

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) 

1405 

1406 def argmin(self, reduction=None, *argmin_args, **argmin_kwargs): 

1407 """ 

1408 Returns the index of the minimum ordinate in the data array 

1409 

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 

1419 

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) 

1431 

1432 def argmax(self, reduction=None, *argmax_args, **argmax_kwargs): 

1433 """ 

1434 Returns the index of the maximum ordinate in the data array 

1435 

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 

1445 

1446 

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) 

1458 

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. 

1475 

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. 

1479 

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. 

1564 

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. 

1570 

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] 

1658 

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) 

1762 

1763 return output_struct 

1764 

1765 def save(self, filename, compress_abscissa = False): 

1766 """ 

1767 Save the array to a numpy file 

1768 

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. 

1778 

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) 

1793 

1794 @classmethod 

1795 def load(cls, filename): 

1796 """ 

1797 Load in the specified file into a SDynPy array object 

1798 

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. 

1805 

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. 

1811 

1812 Returns 

1813 ------- 

1814 cls 

1815 SDynpy array of the appropriate type from the loaded file. 

1816 

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'])]) 

1845 

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. 

1862 

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. 

1866 

1867 

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. 

1944 

1945 Returns 

1946 ------- 

1947 data_dict : TYPE 

1948 DESCRIPTION. 

1949 

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 

1975 

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,) 

1995 

1996 @staticmethod 

1997 def from_unv(unv_data_dict, squeeze=True): 

1998 """ 

1999 Create a data array from a unv dictionary from read_unv 

2000 

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. 

2008 

2009 Returns 

2010 ------- 

2011 return_functions : NDDataArray 

2012 Data read from unv 

2013 

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 

2058 

2059 from_uff = from_unv 

2060 

2061 def get_reciprocal_data(self,return_indices = False): 

2062 """ 

2063 Gets reciprocal pairs of data from an NDDataArray. 

2064 

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. 

2071 

2072 Raises 

2073 ------ 

2074 ValueError 

2075 If the data does not have reference and response coordinates, the 

2076 method will raise a ValueError. 

2077 

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. 

2084 

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] 

2107 

2108 def get_drive_points(self,return_indices=False): 

2109 """ 

2110 Returns data arrays where the reference is equal to the response 

2111 

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. 

2118 

2119 Raises 

2120 ------ 

2121 ValueError 

2122 If the data does not have reference and response coordinates, the 

2123 method will raise a ValueError. 

2124 

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. 

2131 

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] 

2142 

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. 

2147 

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. 

2161 

2162 Raises 

2163 ------ 

2164 ValueError 

2165 Raised if the abscissa are not consistent across all data. 

2166 

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 

2173 

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 

2189 

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 

2202 

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] 

2207 

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) 

2213 

2214 filtered_data = data_array(data_type=data_type, 

2215 abscissa=data.reshape(-1)[0].abscissa, 

2216 ordinate=ordinate, 

2217 coordinate=output_coords) 

2218 

2219 return filtered_data 

2220 

2221 @staticmethod 

2222 def get_abscissa_limits(data_arrays): 

2223 """Compute the smallest overlapping x-axis range across one or more data arrays. 

2224 

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. 

2230 

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 

2251 

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. 

2255 

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. 

2264 

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 

2295 

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 

2302 

2303 @property 

2304 def function_type(self): 

2305 """ 

2306 Returns the function type of the data array 

2307 """ 

2308 return FunctionTypes.TIME_RESPONSE 

2309 

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 

2314 

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. 

2331 

2332 Returns 

2333 ------- 

2334 TYPE 

2335 DESCRIPTION. 

2336 

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]) 

2362 

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 

2367 

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. 

2388 

2389 Raises 

2390 ------ 

2391 ValueError 

2392 Raised if the time signal passed to this function does not have 

2393 equally spaced abscissa. 

2394 

2395 Returns 

2396 ------- 

2397 SpectrumArray 

2398 The frequency spectra of the TimeHistoryArray. 

2399 

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) 

2421 

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 

2425 

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. 

2430 

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 

2446 

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() 

2456 

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 

2460 

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. 

2465 

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 

2481 

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 

2519 

2520 def find_zero_crossings(self,return_abscissa=False, include_start = False): 

2521 """Finds zero crossings in the time history 

2522 

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 

2531 

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 

2556 

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 

2562 

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. 

2581 

2582 Raises 

2583 ------ 

2584 ValueError 

2585 If time history abscissa are not equally spaced. 

2586 

2587 Returns 

2588 ------- 

2589 cpsd_array : PowerSpectralDensityArray 

2590 Cross Power Spectral Density Array. 

2591 

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 

2608 

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 

2614 

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. 

2644 

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. 

2652 

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])) 

2664 

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) 

2672 

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) 

2676 

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 

2683 

2684 def hilbert(self, *hilbert_args, **hilbert_kwargs): 

2685 """Computes the hilbert transform of the signal 

2686 

2687 Any arguments passed to this method will be passed to the `scipy.signal.hilbert` function. 

2688 

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. 

2694 

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 

2707 

2708 def envelope(self, *envelope_args, **envelope_kwargs): 

2709 """Computes the envelope of the time history array. 

2710 

2711 Any arguments passed to htis method will be passed to the `scipy.signal.envelope` function. 

2712 

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()`. 

2722 

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 

2734 

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 

2739 

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. 

2756 

2757 Returns 

2758 ------- 

2759 TimeHistoryArray 

2760 The filtered time history array. 

2761 

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 

2778 

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 

2835 

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 

2867 

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. 

2874 

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. 

2913 

2914 Returns 

2915 ------- 

2916 TimeHistoryArray 

2917 Returns a new TimeHistoryArray with shape [num_frames,...] where 

2918 ... is the shape of the original array. 

2919 

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)) 

2938 

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] 

2952 

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) 

2957 

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') 

2970 

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) 

2974 

2975 def mimo_forward(self, transfer_function): 

2976 """ 

2977 Performs the forward mimo calculation via convolution. 

2978 

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. 

2985 

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. 

2993 

2994 Returns 

2995 ------- 

2996 TimeHistoryArray 

2997 Response time histories 

2998 

2999 """ 

3000 # Converting FRFs to IRFs, if required 

3001 if isinstance(transfer_function, TransferFunctionArray): 

3002 transfer_function = transfer_function.ifft() 

3003 

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 

3012 

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 )) 

3018 

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] 

3023 

3024 return data_array(FunctionTypes.TIME_RESPONSE, self.abscissa[0], convolved_response, response_dofs[..., np.newaxis]) 

3025 

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. 

3044 

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. 

3112 

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. 

3124 

3125 Returns 

3126 ------- 

3127 TimeHistoryArray 

3128 Time history array of the estimated sources 

3129 

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. 

3139 

3140 The 0 Hz component is explicitly rejected from the FRFs, so the estimated forces cannot 

3141 include a 0 Hz component.  

3142  

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() 

3158 

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 

3163 

3164 indexed_response_data = self[response_dofs[..., np.newaxis]] 

3165 

3166 indexed_irf = indexed_transfer_function.ifft() 

3167 model_order = indexed_irf.num_elements 

3168 

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.') 

3174 

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') 

3215 

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) 

3248 

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 

3256 

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) 

3269 

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]) 

3272 

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]) 

3291 

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] 

3299 

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 

3305 

3306 return return_val 

3307 

3308 def rms(self): 

3309 return np.sqrt(np.mean(self.ordinate**2, axis=-1)) 

3310 

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 

3333 

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 

3339 

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`. 

3344 

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`. 

3347 

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. 

3364 

3365 Returns 

3366 ------- 

3367 time_shift : float 

3368 The time difference between the two signals. 

3369 

3370 """ 

3371 this_fft = self.fft() 

3372 

3373 this_ordinate = this_fft.ordinate 

3374 other_ordinate = other_signal.fft().ordinate 

3375 

3376 correlation = scipyfft.irfft(this_ordinate*other_ordinate.conj()) 

3377 time_shift_indices = int(np.mean(np.argmax(correlation, axis=-1))) 

3378 

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) 

3382 

3383 dt = np.mean(np.diff(self.abscissa, axis=-1)) 

3384 time_shift = dt*time_shift_indices 

3385 

3386 if compute_subsample_shift: 

3387 # Now compute the subsample shift 

3388 shifted_ordinate = shifted_signal.fft().ordinate 

3389 

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 

3393 

3394 phase_difference = np.angle(this_ordinate/shifted_ordinate) 

3395 

3396 phase_slope = np.median(phase_difference[good_lines]/this_fft.abscissa[good_lines]) 

3397 

3398 time_shift -= phase_slope/(2*np.pi) 

3399 

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 

3403 

3404 return time_shift 

3405 

3406 def shift_signal(self, time_shift): 

3407 """ 

3408 Shift a signal in time by a specified amount. 

3409 

3410 Utilizes the FFT shift theorem to move a signal in time. 

3411 

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. 

3418 

3419 Returns 

3420 ------- 

3421 shifted_signal : TimeHistoryArray 

3422 A shifted version of the original signal. 

3423 

3424 """ 

3425 phase_shift_slope = -time_shift*2*np.pi 

3426 signal_fft = self.fft() 

3427 

3428 signal_fft.ordinate *= np.exp(1j*phase_shift_slope*signal_fft.flatten()[0].abscissa) 

3429 

3430 shifted_signal = signal_fft.ifft() 

3431 

3432 return shifted_signal 

3433 

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. 

3438 

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 

3449 

3450 

3451 Returns 

3452 ------- 

3453 TimeHistoryArray 

3454 A TimeHistoryArray consisting of the signals overlapped and added 

3455 together. 

3456 

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) 

3478 

3479 def remove_rigid_body_motion(self, geometry): 

3480 """ 

3481 Removes rigid body displacements from time data. 

3482 

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. 

3487 

3488 Parameters 

3489 ---------- 

3490 geometry : Geometry 

3491 Geometry with which the node positions are computed 

3492 

3493 Returns 

3494 ------- 

3495 TimeHistoryArray 

3496 A TimeHistoryArray with the rigid body component of motion removed 

3497 

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] 

3522 

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) 

3529 

3530 The time history is split up into frames with specified length and 

3531 computes the spectra for each frame. 

3532 

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. 

3571 

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 

3587 

3588 def upsample(self, factor): 

3589 """ 

3590 Upsamples a time history using frequency domain zero padding. 

3591 

3592 Parameters 

3593 ---------- 

3594 factor : int 

3595 The upsample factor. 

3596 

3597 Returns 

3598 ------- 

3599 TimeHistoryArray 

3600 A time history with a sample rate that is factor larger than the 

3601 original signal 

3602 

3603 """ 

3604 fft = self.fft() 

3605 fft_zp = fft.zero_pad(fft.num_elements*(factor-1)) 

3606 return fft_zp.ifft()*factor 

3607 

3608 def resample(self, num_samples): 

3609 """ 

3610 Uses Scipy.signal.resample to resample the time history array 

3611 

3612 Parameters 

3613 ---------- 

3614 num_samples : int 

3615 The number of samples in the desired signal. 

3616 

3617 Returns 

3618 ------- 

3619 TimeHistoryArray 

3620 A TimeHistoryArray object with resampled abscissa and ordinate 

3621 

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 ) 

3634 

3635 def apply_transformation(self, transformation, invert_transformation=False): 

3636 """ 

3637 Applies a transformations to the time traces. 

3638 

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. 

3651 

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. 

3659  

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') 

3667 

3668 physical_coordinate = np.unique(self.response_coordinate) 

3669 original_data_ordinate = np.moveaxis(self[physical_coordinate[...,np.newaxis]].ordinate, -1, 0)[..., np.newaxis] 

3670 

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] 

3681 

3682 if transformation_matrix.ndim != 2: 

3683 raise ValueError('The transformation array must be two dimensional') 

3684 

3685 transformed_data_ordinate = (transformation_matrix @ original_data_ordinate)[...,0] 

3686 

3687 return data_array(FunctionTypes.TIME_RESPONSE, self.ravel().abscissa[0], np.moveaxis(transformed_data_ordinate, 0, -1), 

3688 transformed_coordinate[...,np.newaxis]) 

3689 

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 

3699 

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 ''. 

3743 

3744 Returns 

3745 ------- 

3746 TimeHistoryArray : 

3747 A time history containing the specified pseudorandom signal 

3748 

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 

3787 

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 

3796 

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 ''. 

3836 

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 

3872 

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 

3880 

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 ''. 

3914 

3915 Returns 

3916 ------- 

3917 TimeHistoryArray : 

3918 A time history containing the specified sine signal 

3919 

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 

3936 

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 

3947 

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 ''. 

3999 

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 

4044 

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 

4054 

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 ''. 

4094 

4095 Returns 

4096 ------- 

4097 TimeHistoryArray : 

4098 A time history containing the specified chirp signal 

4099 

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 

4134 

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 

4143 

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 ''. 

4177 

4178 Returns 

4179 ------- 

4180 TimeHistoryArray : 

4181 A time history containing the specified pulse signal 

4182 

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 

4206 

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 

4215 

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 ''. 

4247 

4248 Returns 

4249 ------- 

4250 TimeHistoryArray : 

4251 A time history containing the specified haversine pulse signal 

4252 

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 

4280 

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) 

4306 

4307 

4308def time_history_array(abscissa,ordinate,coordinate,comment1='',comment2='',comment3='',comment4='',comment5=''): 

4309 """ 

4310 Helper function to create a TimeHistoryArray object. 

4311 

4312 All input arguments to this function are allowed to broadcast to create the 

4313 final data in the TimeHistoryArray object. 

4314 

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 ''. 

4333 

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) 

4341 

4342 

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 

4349 

4350 @property 

4351 def function_type(self): 

4352 """ 

4353 Returns the function type of the data array 

4354 """ 

4355 return FunctionTypes.SPECTRUM 

4356 

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 

4361 

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. 

4381 

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. 

4389 

4390 Returns 

4391 ------- 

4392 TimeHistoryArray 

4393 The time history of the SpectrumArray. 

4394 

4395 

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 """ 

4405 

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 

4418 

4419 if odd_num_samples: 

4420 num_samples = 2*(num_elements-1)+1 

4421 else: 

4422 num_samples = 2*(num_elements-1) 

4423 

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) 

4429 

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) 

4433 

4434 return data_array(FunctionTypes.TIME_RESPONSE, time_vector, irfft, self.coordinate, 

4435 self.comment1, self.comment2, self.comment3, self.comment4, self.comment5) 

4436 

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 

4443 

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. 

4460 

4461 Returns 

4462 ------- 

4463 SpectrumArray or TimeHistoryArray: 

4464 Spectrum array with appropriately spaced abscissa 

4465 

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 

4486 

4487 def apply_transformation(self, transformation, invert_transformation=False): 

4488 """ 

4489 Applies response transformations spectra. 

4490 

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. 

4503 

4504 Raises 

4505 ------ 

4506 ValueError 

4507 If the physical degrees of freedom in the transformation does not  

4508 match the spectra. 

4509  

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') 

4517 

4518 physical_coordinate = np.unique(self.response_coordinate) 

4519 original_spectra_ordinate = np.moveaxis(self[physical_coordinate[...,np.newaxis]].ordinate, -1, 0)[..., np.newaxis] 

4520 

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] 

4531 

4532 transformed_spectra_ordinate = (transformation_matrix @ original_spectra_ordinate)[...,0] 

4533 

4534 return data_array(FunctionTypes.SPECTRUM, self.ravel().abscissa[0], np.moveaxis(transformed_spectra_ordinate, 0, -1), 

4535 transformed_coordinate[...,np.newaxis]) 

4536 

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 

4543 

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. 

4570 

4571 Returns 

4572 ------- 

4573 axis : matplotlib axis or array of axes 

4574 On which the data were plotted 

4575 

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)] 

4583 

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 

4679 

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 

4686 

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 

4702 

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 

4721 

4722 

4723def spectrum_array(abscissa,ordinate,coordinate,comment1='',comment2='', 

4724 comment3='',comment4='',comment5=''): 

4725 """ 

4726 Helper function to create a SpectrumArray object. 

4727 

4728 All input arguments to this function are allowed to broadcast to create the 

4729 final data in the SpectrumArray object. 

4730 

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 ''. 

4749 

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) 

4757 

4758 

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 

4765 

4766 @property 

4767 def function_type(self): 

4768 """ 

4769 Returns the function type of the data array 

4770 """ 

4771 return FunctionTypes.POWER_SPECTRAL_DENSITY 

4772 

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 

4782 

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. 

4801 

4802 Raises 

4803 ------ 

4804 ValueError 

4805 Raised if reference and response functions do not have consistent 

4806 abscissa 

4807 

4808 Returns 

4809 ------- 

4810 PowerSpectralDensityArray 

4811 A PSD array computed from the specified reference and 

4812 response signals. 

4813 

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) 

4844 

4845 def generate_time_history(self, time_length=None, output_oversample=1): 

4846 """ 

4847 Generates a time history from a CPSD matrix 

4848 

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. 

4860 

4861 Raises 

4862 ------ 

4863 ValueError 

4864 If the entries in the CPSD matrix do not have consistent abscissa 

4865 or equally spaced frequency bins. 

4866 

4867 Returns 

4868 ------- 

4869 time_history : TimeHistoryArray 

4870 A time history satisfying the properties of the CPSD matrix. 

4871 

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 

4910 

4911 def mimo_forward(self, transfer_function): 

4912 """ 

4913 Compute the forward MIMO problem Gxx = Hxv@Gvv@Hxv* 

4914 

4915 Parameters 

4916 ---------- 

4917 transfer_function : TransferFunctionArray 

4918 Transfer function used to transform the input matrix to the 

4919 response matrix 

4920 

4921 Raises 

4922 ------ 

4923 ValueError 

4924 If abscissa do not match between self and transfer function 

4925 

4926 Returns 

4927 ------- 

4928 PowerSpectralDensityArray 

4929 Response CPSD matrix 

4930 

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) 

4950 

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 

4961 

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. 

5014 

5015 Raises 

5016 ------ 

5017 ValueError 

5018 If Abscissa are not consistent 

5019 

5020 Returns 

5021 ------- 

5022 PowerSpectralDensityArray 

5023 Input CPSD matrix 

5024 

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. 

5029 

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) 

5081 

5082 def error_summary(self, figure_kwargs={}, linewidth=1, plot_kwargs={}, **cpsd_matrices): 

5083 """ 

5084 Plots an error summary compared to the current array 

5085 

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. 

5097 

5098 Raises 

5099 ------ 

5100 ValueError 

5101 If CPSD abscissa do not match 

5102 

5103 Returns 

5104 ------- 

5105 Error Metrics 

5106 A tuple of dictionaries of error metrics 

5107 

5108 """ 

5109 def rms(x, axis=None): 

5110 return np.sqrt(np.mean(x**2, axis=axis)) 

5111 

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 

5201 

5202 def svd(self, full_matrices=True, compute_uv=True, as_matrix=True): 

5203 """ 

5204 Compute the SVD of the provided CPSD matrix 

5205 

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 

5215 

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 

5246 

5247 def get_asd(self): 

5248 """ 

5249 Get functions where the response coordinate is equal to the reference coordinate 

5250 

5251 Returns 

5252 ------- 

5253 PowerSpectralDensityArray 

5254 PowerSpectralDensityArrays where the response is equal to the reference 

5255 

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) 

5265 

5266 def rms(self,oct_order=None): 

5267 """ 

5268 Compute RMSs of the PSDs using the diagonals 

5269  

5270 Parameters 

5271 ---------- 

5272 oct_order : int, optional 

5273 octave type, 1/octave order. 3 represents 1/3 octave bands. default is None 

5274 

5275 Returns 

5276 ------- 

5277 ndarray 

5278 RMS values for the ASDS 

5279 

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 

5294 

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 

5307 

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 

5312 

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. 

5323 

5324 Raises 

5325 ------ 

5326 ValueError 

5327 If degrees of freedom are not consistent between PSDs 

5328 

5329 Returns 

5330 ------- 

5331 None. 

5332 

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) 

5400 

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 

5404 

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 

5415 

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) 

5432 

5433 def coherence(self): 

5434 """ 

5435 Computes the coherence of a PSD matrix 

5436 

5437 Raises 

5438 ------ 

5439 ValueError 

5440 If abscissa are not consistent. 

5441 

5442 Returns 

5443 ------- 

5444 CoherenceArray 

5445 CoherenceArray containing the values of coherence for each function. 

5446 

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] 

5458 

5459 def angle(self): 

5460 """ 

5461 Computes the angle of a PSD matrix 

5462 

5463 Returns 

5464 ------- 

5465 NDDataArray 

5466 Data array consisting of the angle of each function at each 

5467 frequency line 

5468 

5469 """ 

5470 return data_array(FunctionTypes.GENERAL, self.abscissa, np.angle(self.ordinate), 

5471 self.coordinate) 

5472 

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 

5476 

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 

5483 

5484 Returns 

5485 ------- 

5486 output : PowerSpectralDensityArray 

5487 PSD with coherence and phase matching that of the input argument 

5488 

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 

5502 

5503 def get_cpsd_from_asds(self): 

5504 """ 

5505 Transforms ASDs to a full CPSD matrix with zeros on the off-diagonals 

5506 

5507 Returns 

5508 ------- 

5509 output : PowerSpectralDensityArray 

5510 CPSD matrix with the inputs on the diagonals and the off-diagonals 

5511 as zeros. 

5512 

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 

5524 

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 

5532 

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. 

5557 

5558 Raises 

5559 ------ 

5560 ValueError 

5561 If invalid interpolation is specified, or if RMS is specified with 

5562 inconsistent frequency spacing. 

5563 

5564 Returns 

5565 ------- 

5566 PowerSpectralDensityArray 

5567 A set of PSDs. 

5568 

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"') 

5580 

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 

5585 

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 

5592 

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)) 

5601 

5602 return data_array(FunctionTypes.POWER_SPECTRAL_DENSITY, frequencies, 

5603 full_cpsd, cpsd_coordinates) 

5604 

5605 def apply_transformation(self, transformation, invert_transformation=False): 

5606 """ 

5607 Applies a transformation to a cross power spectral density matrix. 

5608 

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. 

5621 

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. 

5629  

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') 

5637 

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') 

5640 

5641 physical_coordinate = np.unique(self.response_coordinate) 

5642 original_spectra_ordinate = np.moveaxis(self[outer_product(physical_coordinate, physical_coordinate)].ordinate, -1, 0) 

5643 

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] 

5654 

5655 if transformation_matrix.ndim == 2: 

5656 transformation_matrix = transformation_matrix[np.newaxis,...] # this ensures the transpose in the next line works 

5657 

5658 transformed_spectra_ordinate = transformation_matrix @ original_spectra_ordinate @ np.transpose(transformation_matrix.conj(), (0, 2, 1)) 

5659 

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)) 

5662 

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. 

5672 

5673 Coherence is plotted on the upper triangle, phase on the lower triangle, 

5674 and magnitude on the diagonal. 

5675 

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 {}. 

5703 

5704 Returns 

5705 ------- 

5706 None. 

5707 

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') 

5775 

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 

5853 

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) 

5858 

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 

5865 

5866 Returns 

5867 ------- 

5868 PowerSpectralDensityArray with abscissa given by the mean of band_lb 

5869 and band_ub 

5870  

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 

5875  

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' 

5887 

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') 

5894 

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 

5899 

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)) 

5902 

5903 # Get PSD 

5904 psd_ave = np.einsum(ein_str,bin_to_band,df*self.ordinate) 

5905 

5906 psd_ave = psd_ave/(band_ub[:,0]-band_lb[:,0]) 

5907 freqs = np.concatenate( (band_lb,band_ub) ,1).mean(1) 

5908 

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) 

5912 

5913 

5914def power_spectral_density_array(abscissa,ordinate,coordinate, 

5915 comment1='',comment2='', 

5916 comment3='',comment4='',comment5=''): 

5917 """ 

5918 Helper function to create a PowerSpectralDensityArray object. 

5919 

5920 All input arguments to this function are allowed to broadcast to create the 

5921 final data in the PowerSpectralDensityArray object. 

5922 

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 ''. 

5941 

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) 

5949 

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 

5956 

5957 @property 

5958 def function_type(self): 

5959 """ 

5960 Returns the function type of the data array 

5961 """ 

5962 return FunctionTypes.AUTOSPECTRUM 

5963 

5964 

5965def power_spectrum_array(abscissa,ordinate,coordinate, 

5966 comment1='',comment2='', 

5967 comment3='',comment4='',comment5=''): 

5968 """ 

5969 Helper function to create a PowerSpectrumArray object. 

5970 

5971 All input arguments to this function are allowed to broadcast to create the 

5972 final data in the PowerSpectrumArray object. 

5973 

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 ''. 

5992 

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) 

6000 

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 

6007 

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 

6017 

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. 

6043 

6044 

6045 Raises 

6046 ------ 

6047 ValueError 

6048 Raised if reference and response functions do not have consistent 

6049 abscissa 

6050 

6051 Returns 

6052 ------- 

6053 TransferFunctionArray 

6054 A transfer function array computed from the specified references and 

6055 responses. 

6056 

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) 

6086 

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. 

6092 

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. 

6105 

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. 

6116 

6117 Returns 

6118 ------- 

6119 ImpulseResponseFunctionArray 

6120 The impulse response function array computed from the transfer function 

6121 array. 

6122 """ 

6123 

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 

6136 

6137 if odd_num_samples: 

6138 num_samples = 2*(num_elements-1)+1 

6139 else: 

6140 num_samples = 2*(num_elements-1) 

6141 

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) 

6147 

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) 

6151 

6152 return data_array(FunctionTypes.IMPULSE_RESPONSE_FUNCTION, time_vector, irfft, self.coordinate, 

6153 self.comment1, self.comment2, self.comment3, self.comment4, self.comment5) 

6154 

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. 

6162 

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. 

6195 

6196 Returns 

6197 ------- 

6198 TransferFunctionArray 

6199 The FRF with causality enforced. 

6200 

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. 

6205 

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. 

6213 

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. 

6217 

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/ 

6222 

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() 

6229 

6230 def svd(self, full_matrices=True, compute_uv=True, as_matrix=True): 

6231 """ 

6232 Compute the SVD of the provided FRF matrix 

6233 

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 

6245 

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 

6276 

6277 def compute_mif(self, mif_type, *mif_args, **mif_kwargs): 

6278 """ 

6279 Compute a mode indicator functions from the transfer functions 

6280 

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 

6289 

6290 Raises 

6291 ------ 

6292 ValueError 

6293 If an invalid mif name is provided. 

6294 

6295 Returns 

6296 ------- 

6297 ModeIndicatorFunctionArray 

6298 Mode indicator function 

6299 

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') 

6309 

6310 def compute_cmif(self, part='both', tracking=None): 

6311 """ 

6312 Computes a complex mode indicator function from the 

6313 TransferFunctionArray 

6314 

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. 

6324 

6325 Raises 

6326 ------ 

6327 ValueError 

6328 Raised if an invalid tracking is specified 

6329 

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 

6377 

6378 def compute_nmif(self, part='real'): 

6379 """ 

6380 Computes a normal mode indicator function from the 

6381 TransferFunctionArray 

6382 

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'. 

6389 

6390 Raises 

6391 ------ 

6392 ValueError 

6393 Raised if an invalid part is specified 

6394 

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 

6415 

6416 def compute_mmif(self, part='real', mass_matrix=None): 

6417 """ 

6418 Computes a Multi Mode indicator function from the 

6419 TransferFunctionArray 

6420 

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 

6429 

6430 Raises 

6431 ------ 

6432 ValueError 

6433 Raised if an invalid part is specified 

6434 

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 

6466 

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 

6470 

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. 

6477 

6478 Returns 

6479 ------- 

6480 None. 

6481 

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) 

6509 

6510 return figure, axis 

6511 

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 

6520 

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) 

6585 

6586 return figure, axis 

6587 

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 

6594 

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. 

6626 

6627 Returns 

6628 ------- 

6629 axis : matplotlib axis or array of axes 

6630 On which the data were plotted 

6631 

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)] 

6639 

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') 

6705 

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 

6870 

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) 

6887 

6888 def delay_response(self, dt): 

6889 """ 

6890 Adjusts the FRF phases as if the response had been shifted `dt` in time 

6891 

6892 Parameters 

6893 ---------- 

6894 dt : float 

6895 Time shift to apply to the responses 

6896 

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 

6906 

6907 @property 

6908 def function_type(self): 

6909 """ 

6910 Returns the function type of the data array 

6911 """ 

6912 return FunctionTypes.FREQUENCY_RESPONSE_FUNCTION 

6913 

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. 

6919 

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. 

6946 

6947 Raises 

6948 ------ 

6949 ValueError 

6950 If the physical degrees of freedom in the transformations don't 

6951 match the transfer functions 

6952  

6953 Returns 

6954 ------- 

6955 transformed_transfer_function : TransferFunctionArray 

6956 The transfer functions with the transformations applied. 

6957 

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.  

6963 

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') 

6975 

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) 

6979 

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] 

6994 

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] 

7009 

7010 transformed_frf_ordinate = response_transformation_matrix @ original_frf_ordinate @ reference_transformation_matrix 

7011 

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)) 

7014 

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 

7020 

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. 

7037 

7038 Returns 

7039 ------- 

7040 TransferFunctionArray or ImpulseResponseFunctionArray: 

7041 Transfer function array with appropriately spaced abscissa 

7042 

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 

7062 

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. 

7068 

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. 

7076 

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. 

7083  

7084 Returns 

7085 ------- 

7086 TransferFunctionArray 

7087 The FRFs organized in block diagonal format. 

7088 

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] 

7108 

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() 

7117 

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 

7122 

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()) 

7132 

7133 # Adding the slicing offset for the block diagonal FRFs 

7134 response_index_offset += frfs.shape[0] 

7135 reference_index_offset += frfs.shape[1] 

7136 

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) 

7141 

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.  

7146 

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]. 

7154 

7155 Raises 

7156 ------ 

7157 ValueError 

7158 If listed degrees of freedom are not found in the function. 

7159 

7160 Returns 

7161 ------- 

7162 constrained_frfs : TransferFunctionArray 

7163 Constrained Frequency Response Functions 

7164 

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 

7197 

7198 def substructure_by_coordinate(self, dof_pairs): 

7199 """ 

7200 Performs frequency based substructuring by constraining pairs of degrees 

7201 of freedom 

7202 

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]. 

7209 

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) 

7237 

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 

7244 

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' 

7270 

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)) 

7296 

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) 

7302 

7303 return transfer_function_array(exo.time,data,coordinates) 

7304 

7305 

7306def transfer_function_array(abscissa,ordinate,coordinate, 

7307 comment1='',comment2='', 

7308 comment3='',comment4='',comment5=''): 

7309 """ 

7310 Helper function to create a TransferFunctionArray object. 

7311 

7312 All input arguments to this function are allowed to broadcast to create the 

7313 final data in the TransferFunctionArray object. 

7314 

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 ''. 

7333 

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) 

7341 

7342 

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 

7349 

7350 @property 

7351 def function_type(self): 

7352 """ 

7353 Returns the function type of the data array 

7354 """ 

7355 return FunctionTypes.IMPULSE_RESPONSE_FUNCTION 

7356 

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. 

7361 

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. 

7368 

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) 

7378 

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] 

7382 

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) 

7386 

7387 # Broadcasting the frequency vector to the correct size 

7388 abscissa = np.broadcast_to(freq_vector[..., np.newaxis, np.newaxis], frf_ordinate.shape) 

7389 

7390 return data_array(FunctionTypes.FREQUENCY_RESPONSE_FUNCTION, np.moveaxis(abscissa, 0, -1), 

7391 np.moveaxis(frf_ordinate, 0, -1), irfs.coordinate) 

7392 

7393 def find_end_of_ringdown(self): 

7394 """ 

7395 Finds the end of the ringdown in a impulse response function (IRF). 

7396 

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. 

7401 

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) 

7408 

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') 

7415 

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)) 

7420 

7421 return end_of_ringdown 

7422 

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. 

7429 

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. 

7462 

7463 Returns 

7464 ------- 

7465 ImpulseResponseFunctionArray 

7466 The IRF with causality enforced. 

7467 

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. 

7477 

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. 

7481 

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) 

7490 

7491 if method == 'exponential_taper' and end_of_ringdown is None: 

7492 end_of_ringdown = self.find_end_of_ringdown() 

7493 

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') 

7496 

7497 if method in ['exponential', 'boxcar'] and window_parameter is None: 

7498 window_parameter = self.find_end_of_ringdown() 

7499 

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 

7517 

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 

7521 

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)) 

7525 

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] 

7529 

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) 

7533 

7534 return irfs_causal 

7535 

7536def impulse_response_function_array(abscissa,ordinate,coordinate, 

7537 comment1='',comment2='', 

7538 comment3='',comment4='',comment5=''): 

7539 """ 

7540 Helper function to create a ImpulseResponseFunctionArray object. 

7541 

7542 All input arguments to this function are allowed to broadcast to create the 

7543 final data in the ImpulseResponseFunctionArray object. 

7544 

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 ''. 

7563 

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) 

7571 

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 

7578 

7579 @property 

7580 def function_type(self): 

7581 """ 

7582 Returns the function type of the data array 

7583 """ 

7584 return FunctionTypes.TRANSMISIBILITY 

7585 

7586 

7587def transmissibility_array(abscissa,ordinate,coordinate, 

7588 comment1='',comment2='', 

7589 comment3='',comment4='',comment5=''): 

7590 """ 

7591 Helper function to create a TransmissibilityArray object. 

7592 

7593 All input arguments to this function are allowed to broadcast to create the 

7594 final data in the TransmissibilityArray object. 

7595 

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 ''. 

7614 

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) 

7622 

7623 

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 

7630 

7631 @property 

7632 def function_type(self): 

7633 """ 

7634 Returns the function type of the data array 

7635 """ 

7636 return FunctionTypes.COHERENCE 

7637 

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 

7646 

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. 

7665 

7666 Raises 

7667 ------ 

7668 ValueError 

7669 Raised if reference and response functions do not have consistent 

7670 abscissa 

7671 

7672 Returns 

7673 ------- 

7674 PowerSpectralDensityArray 

7675 A PSD array computed from the specified reference and 

7676 response signals. 

7677 

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) 

7706 

7707 

7708def coherence_array(abscissa,ordinate,coordinate, 

7709 comment1='',comment2='', 

7710 comment3='',comment4='',comment5=''): 

7711 """ 

7712 Helper function to create a CoherenceArray object. 

7713 

7714 All input arguments to this function are allowed to broadcast to create the 

7715 final data in the CoherenceArray object. 

7716 

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 ''. 

7735 

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) 

7743 

7744 

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 

7751 

7752 @property 

7753 def function_type(self): 

7754 """ 

7755 Returns the function type of the data array 

7756 """ 

7757 return FunctionTypes.MULTIPLE_COHERENCE 

7758 

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 

7767 

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. 

7786 

7787 Raises 

7788 ------ 

7789 ValueError 

7790 Raised if reference and response functions do not have consistent 

7791 abscissa 

7792 

7793 Returns 

7794 ------- 

7795 PowerSpectralDensityArray 

7796 A PSD array computed from the specified reference and 

7797 response signals. 

7798 

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) 

7817 

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) 

7825 

7826def multiple_coherence_array(abscissa,ordinate,coordinate, 

7827 comment1='',comment2='', 

7828 comment3='',comment4='',comment5=''): 

7829 """ 

7830 Helper function to create a MultipleCoherenceArray object. 

7831 

7832 All input arguments to this function are allowed to broadcast to create the 

7833 final data in the MultipleCoherenceArray object. 

7834 

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 ''. 

7853 

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) 

7861 

7862 

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 

7871 

7872def correlation_array(abscissa,ordinate,coordinate, 

7873 comment1='',comment2='', 

7874 comment3='',comment4='',comment5=''): 

7875 """ 

7876 Helper function to create a CorrelationArray object. 

7877 

7878 All input arguments to this function are allowed to broadcast to create the 

7879 final data in the CorrelationArray object. 

7880 

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 ''. 

7899 

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) 

7907 

7908 

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 

7915 

7916 @property 

7917 def function_type(self): 

7918 """ 

7919 Returns the function type of the data array 

7920 """ 

7921 return FunctionTypes.MODE_INDICATOR_FUNCTION 

7922 

7923def mode_indicator_function_array(abscissa,ordinate,coordinate, 

7924 comment1='',comment2='', 

7925 comment3='',comment4='',comment5=''): 

7926 """ 

7927 Helper function to create a ModeIndicatorFunctionArray object. 

7928 

7929 All input arguments to this function are allowed to broadcast to create the 

7930 final data in the ModeIndicatorFunctionArray object. 

7931 

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 ''. 

7950 

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) 

7958 

7959class ShockResponseSpectrumArray(NDDataArray): 

7960 

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} 

7981 

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 

7987 

7988 @property 

7989 def function_type(self): 

7990 """ 

7991 Returns the function type of the data array 

7992 """ 

7993 return FunctionTypes.SHOCK_RESPONSE_SPECTRUM 

7994 

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. 

8012 

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. 

8016 

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. 

8138 

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])) 

8164 

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 

8174 

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) 

8179 

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) 

8184 

8185 for index, srs_fn in self.ndenumerate(): 

8186 

8187 if sine_delays is not None: 

8188 delays = sine_delays[index] 

8189 else: 

8190 delays = None 

8191 

8192 if sine_decays is not None: 

8193 decays = sine_decays[index] 

8194 else: 

8195 decays = None 

8196 

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 

8201 

8202 srs_breakpoints = np.array((srs_fn.abscissa, srs_fn.ordinate)).T 

8203 

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) 

8215 

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) 

8236 

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,) 

8264 

8265 if len(return_values) == 1: 

8266 return_values = return_values[0] 

8267 

8268 return return_values 

8269 

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 

8276 

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. 

8303 

8304 Returns 

8305 ------- 

8306 axis : matplotlib axis or array of axes 

8307 On which the data were plotted 

8308 

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)] 

8316 

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 

8391 

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 

8400  

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. 

8407 

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. 

8449 

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. 

8470  

8471 """ 

8472 

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)] 

8476 

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. 

8482 

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). 

8488 

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 

8495 

8496 # Precompute the pseudoinverse of A 

8497 A_pinv = np.linalg.pinv(A,rcond=rcond) 

8498 

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) 

8503 

8504 # Compute x using the pseudoinverse 

8505 x = A_pinv @ b 

8506 

8507 # Compute accuracy term: ||Ax - b||_2^2 

8508 Ax = A @ x 

8509 accuracy_term = np.sum((np.abs(Ax) - np.abs(b))**2) 

8510 

8511 # Compute magnitude term: ||x||_2^2 

8512 magnitude_term = np.sum(np.abs(x)**2) 

8513 

8514 # Weighted objective function 

8515 return weight_accuracy * accuracy_term + weight_magnitude * magnitude_term 

8516 

8517 # Initial guess for phi (zero phase) 

8518 if phi0 is None: 

8519 phi0 = np.zeros(m) 

8520 

8521 # Optimize the phase of b 

8522 result = minimize(objective, phi0, method='L-BFGS-B', bounds=[(-np.pi, np.pi)] * m) 

8523 

8524 # Optimal phase and corresponding b 

8525 phi_opt = result.x 

8526 b_opt = b_amplitude * np.exp(1j * phi_opt) 

8527 

8528 # Compute the optimal x using the pseudoinverse 

8529 x_opt = A_pinv @ b_opt 

8530 

8531 return x_opt, b_opt, result 

8532 

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) 

8544 

8545 # Set up the initial optimization problem 

8546 x_opt = [] 

8547 b_opt = [] 

8548 result = [] 

8549 

8550 A_all = transfer_function.interpolate(srs_frequencies).ordinate.transpose(2,0,1) 

8551 b_all = control_table.amplitude[:,:-1].T 

8552 

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) 

8559 

8560 x_opt = np.array(x_opt) 

8561 b_opt = np.array(b_opt) 

8562 

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] 

8568 

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) 

8577 

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 = [] 

8583 

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) 

8589 

8590 x_opt = np.array(x_opt).T 

8591 b_opt2 = np.array(b_opt2).T 

8592 

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] 

8598 

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) 

8602 

8603 return_vals = [] 

8604 

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) 

8617 

8618 return_vals=tuple(return_vals) 

8619 if len(return_vals) == 1: 

8620 return_vals = return_vals[0] 

8621 

8622 return return_vals 

8623 

8624def shock_response_spectrum_array(abscissa,ordinate,coordinate, 

8625 comment1='',comment2='', 

8626 comment3='',comment4='',comment5=''): 

8627 """ 

8628 Helper function to create a ShockResponseSpectrumArray object. 

8629 

8630 All input arguments to this function are allowed to broadcast to create the 

8631 final data in the ShockResponseSpectrumArray object. 

8632 

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 ''. 

8651 

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) 

8659 

8660 

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 } 

8692 

8693 

8694def data_array(data_type, abscissa, ordinate, coordinate, comment1='', comment2='', comment3='', comment4='', comment5=''): 

8695 """ 

8696 Helper function to create a data array object. 

8697 

8698 All input arguments to this function are allowed to broadcast to create the 

8699 final data in the NDDataArray object. 

8700 

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 ''. 

8721 

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 

8747 

8748 

8749from_unv = NDDataArray.from_unv 

8750from_uff = from_unv 

8751load = NDDataArray.load 

8752 

8753 

8754def from_imat_struct(imat_fn_struct, squeeze=True): 

8755 """ 

8756 Constructs a NDDataArray from an imat_fn class saved to a Matlab structure 

8757 

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 

8761 

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. 

8772 

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. 

8779 

8780 """ 

8781 

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 

8839 

8840 

8841class DecayedSineTable(SdynpyArray): 

8842 """Structure for storing sum-of-decayed-sines information 

8843 """ 

8844 

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 

8866 

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 

8873 

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) 

8886 

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) 

8899 

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) 

8912 

8913 

8914def decayed_sine_table(frequency, amplitude, decay, delay, coordinate, comment1='', comment2='', comment3='', comment4='', comment5=''): 

8915 """ 

8916 Helper function to create a DecayedSineTable object. 

8917 

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 ''. 

8942 

8943 Returns 

8944 ------- 

8945 SineTable : 

8946 A SineTable object containing the specified information 

8947 

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 

8966 

8967 

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 

8976 

8977 

8978class GUIPlot(QMainWindow): 

8979 """An iteractive plot window allowing users to visualize data""" 

8980 

8981 def __init__(self, *data_to_plot, **labeled_data_to_plot): 

8982 """ 

8983 Create a GUIPlot window to visualize data. 

8984 

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 ` `. 

8989 

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. 

9027 

9028 Returns 

9029 ------- 

9030 None. 

9031 

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() 

9150 

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!') 

9204 

9205 def connect_callbacks(self): 

9206 """ 

9207 Connects the callback functions to events 

9208 

9209 Returns 

9210 ------- 

9211 None. 

9212 

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) 

9227 

9228 def update(self): 

9229 """ 

9230 Updates the figure in the GUIPlot 

9231 

9232 Returns 

9233 ------- 

9234 None. 

9235 

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) 

9328 

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) 

9334 

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) 

9381 

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 

9401 

9402 self.update() 

9403 

9404 def selection_changed(self): 

9405 """Called when the selected functions is changed""" 

9406 if self.autoupdate_checkbox.isChecked(): 

9407 self.update() 

9408 

9409 def deselect_all_complex_types_except(self, complex_type): 

9410 """ 

9411 Deselects all complex types except the specified type. 

9412 

9413 Makes the checkboxes in the menu act like radiobuttons 

9414 

9415 Parameters 

9416 ---------- 

9417 complex_type : ComplexType 

9418 Enumeration specifying which complex plot type is selected 

9419 

9420 Returns 

9421 ------- 

9422 None. 

9423 

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() 

9434 

9435 def set_imaginary(self): 

9436 """Sets the complex type to imaginary""" 

9437 self.deselect_all_complex_types_except(ComplexType.IMAGINARY) 

9438 

9439 def set_real(self): 

9440 """Sets the complex type to real""" 

9441 self.deselect_all_complex_types_except(ComplexType.REAL) 

9442 

9443 def set_magnitude(self): 

9444 """Sets the complex type to magnitude""" 

9445 self.deselect_all_complex_types_except(ComplexType.MAGNITUDE) 

9446 

9447 def set_phase(self): 

9448 """Sets the complex type to phase""" 

9449 self.deselect_all_complex_types_except(ComplexType.PHASE) 

9450 

9451 def set_magnitude_phase(self): 

9452 """Sets the complex type to magnitude and phase""" 

9453 self.deselect_all_complex_types_except(ComplexType.MAGPHASE) 

9454 

9455 def set_real_imag(self): 

9456 """Sets the complex type to real and imaginary""" 

9457 self.deselect_all_complex_types_except(ComplexType.REALIMAG) 

9458 

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() 

9464 

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() 

9470 

9471 def update_checkbox(self): 

9472 """Disables the update button if set to auto-update""" 

9473 self.pushButton.setEnabled(not self.autoupdate_checkbox.isChecked()) 

9474 

9475 

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) 

9481 

9482 FigureCanvas.__init__(self, fig) 

9483 self.setParent(parent) 

9484 

9485 FigureCanvas.setSizePolicy(self, QSizePolicy.Expanding, QSizePolicy.Expanding) 

9486 FigureCanvas.updateGeometry(self) 

9487 

9488 

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) 

9501 

9502 FigureCanvas.__init__(self, self.fig) 

9503 self.setParent(parent) 

9504 

9505 FigureCanvas.setSizePolicy(self, QSizePolicy.Expanding, QSizePolicy.Expanding) 

9506 FigureCanvas.updateGeometry(self) 

9507 

9508 

9509class CPSDPlot(QMainWindow): 

9510 

9511 class DataType(Enum): 

9512 MAGNITUDE = 1 

9513 COHERENCE = 2 

9514 PHASE = 4 

9515 REAL = 8 

9516 IMAGINARY = 16 

9517 

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() 

9545 

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) 

9644 

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()) 

9657 

9658 self.settext() 

9659 

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...") 

9690 

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) 

9714 

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)) 

9750 

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() 

9768 

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) 

9776 

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() 

9808 

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() 

9815 

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() 

9822 

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() 

9829 

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() 

9836 

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() 

9843 

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() 

9850 

9851 def clear_selection(self, event): 

9852 self.selected_function[:] = False 

9853 self.update_selection() 

9854 

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() 

9860 

9861 def select_plotted(self, event): 

9862 self.matrix_select_checkbox.setChecked(False) 

9863 self.selected_function[:] = self.plotted_function[:] 

9864 self.update_selection() 

9865 

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 

9876 

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 

9886 

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 

9896 

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 

9906 

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 

9916 

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() 

9928 

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() 

9945 

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, :] 

9950 

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() 

9956 

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) 

9967 

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() 

10049 

10050 

10051frf_from_time_data = TransferFunctionArray.from_time_data 

10052join = NDDataArray.join