Coverage for src / sdynpy / doc / sdynpy_vibration_test.py: 9%

611 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-17 21:21 +0000

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

2""" 

3Data container and automated plotting tools for random vibration tests. 

4 

5Public API 

6---------- 

7RandomVibTest 

8 Container for a random vibration test data set. Holds time history data, 

9 cross-power spectral densities, and specification PSDs. Provides methods 

10 to compute CPSDs/FRFs and produce a standard suite of diagnostic figures. 

11optimal_subset 

12 Identify the time window with the most stable RMS amplitude. 

13make_multi_figures 

14 Create paginated grids of subplots for multi-channel data. 

15dynamic_barh 

16 Horizontal bar chart with automatic multi-column layout. 

17response_outside_mask 

18 Boolean mask of frequency lines where a response exceeds a limit. 

19 

20Copyright 2022 National Technology & Engineering Solutions of Sandia, 

21LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the U.S. 

22Government retains certain rights in this software. 

23 

24This program is free software: you can redistribute it and/or modify 

25it under the terms of the GNU General Public License as published by 

26the Free Software Foundation, either version 3 of the License, or 

27(at your option) any later version. 

28 

29This program is distributed in the hope that it will be useful, 

30but WITHOUT ANY WARRANTY; without even the implied warranty of 

31MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

32GNU General Public License for more details. 

33 

34You should have received a copy of the GNU General Public License 

35along with this program. If not, see <https://www.gnu.org/licenses/>. 

36""" 

37 

38from ..core.sdynpy_data import (TransferFunctionArray, CoherenceArray, MultipleCoherenceArray, PowerSpectralDensityArray, TimeHistoryArray, NDDataArray) 

39from ..core.sdynpy_geometry import (Geometry,GeometryPlotter,ShapePlotter) 

40from ..core.sdynpy_coordinate import CoordinateArray, coordinate_array as sd_coordinate_array 

41from ..modal.sdynpy_signal_processing_gui import SignalProcessingGUI 

42from ..fileio.sdynpy_rattlesnake import RattlesnakeData, RattlesnakeRandomEnvironmentData 

43import matplotlib.pyplot as plt 

44from matplotlib.patches import Patch 

45from matplotlib.ticker import LogLocator 

46import os 

47import netCDF4 as nc4 

48import numpy as np 

49import pandas as pd 

50import sdynpy as sdpy 

51import warnings 

52import math 

53from scipy.stats import kurtosis 

54 

55# Define Functions 

56def optimal_subset(time_data: TimeHistoryArray,num_subset_samples: int, amplitude_factor: float=0.5): 

57 """Find the start index of the time subset with most stable amplitude. 

58 

59 Searches for a contiguous window of ``num_subset_samples`` samples that 

60 minimises the standard deviation of RMS amplitude across channels while 

61 remaining above a minimum amplitude threshold. 

62 

63 Parameters 

64 ---------- 

65 time_data : TimeHistoryArray 

66 Time-domain data whose ordinate has shape ``(n_channels, n_samples)``. 

67 num_subset_samples : int 

68 Length of the sliding window in samples. 

69 amplitude_factor : float, optional 

70 Fraction of the peak-to-trough mean range used as the minimum 

71 acceptable window mean amplitude. Windows below this threshold are 

72 penalised. Default is 0.5. 

73 

74 Returns 

75 ------- 

76 int 

77 Sample index where the optimal subset begins. 

78 """ 

79 ordinate_rms = np.sqrt(np.mean(time_data.ordinate**2,axis=0)) 

80 

81 # Compute Cumulative Sums 

82 cumsum1 = np.concatenate([[0],np.cumsum(ordinate_rms)]) 

83 cumsum2 = np.concatenate([[0],np.cumsum(ordinate_rms**2)]) 

84 

85 # Compute Variance and Standard Deviation 

86 window_mean = (cumsum1[num_subset_samples:] - cumsum1[:-num_subset_samples]) / num_subset_samples 

87 window_variance = (cumsum2[num_subset_samples:] - cumsum2[:-num_subset_samples]) / num_subset_samples - window_mean**2 

88 window_std = np.sqrt(window_variance) 

89 

90 # Add threashold for mean data 

91 threshold = (max(window_mean) - min(window_mean))*amplitude_factor + min(window_mean) 

92 score = np.where(window_mean > threshold, window_std, np.inf) 

93 

94 # Define Window Start 

95 start_index = np.argmin(score) 

96 

97 return start_index 

98 

99def make_multi_figures(total_plots, max_cols=3, max_subplots_per_fig=18,xlabel='Frequency (Hz)',ylabel='|PSD| (EU$^2$/Hz)',fig_size_x=8.5,fig_size_y=11): 

100 """Create one or more figures, each containing a grid of subplots. 

101 

102 Parameters 

103 ---------- 

104 total_plots : int 

105 Total number of subplots to create across all figures. 

106 max_cols : int, optional 

107 Maximum number of subplot columns per figure. Default is 3. 

108 max_subplots_per_fig : int, optional 

109 Maximum subplots per figure before a new figure is started. Default 

110 is 18. 

111 xlabel : str, optional 

112 Label for the x-axis of each subplot. Default is ``'Frequency (Hz)'``. 

113 ylabel : str, optional 

114 Label for the y-axis of each subplot. Default is ``'|PSD| (EU$^2$/Hz)'``. 

115 fig_size_x : float, optional 

116 Figure width in inches. Default is 8.5. 

117 fig_size_y : float, optional 

118 Figure height in inches. Default is 11. 

119 

120 Returns 

121 ------- 

122 figs : list of matplotlib.figure.Figure 

123 List of created figures. 

124 all_axes : list of list of matplotlib.axes.Axes 

125 Nested list where ``all_axes[i]`` contains the axes for ``figs[i]``. 

126 """ 

127 figs = [] 

128 all_axes = [] 

129 

130 start_idx = 0 

131 

132 while start_idx < total_plots: 

133 # Determine how many plots in the figure 

134 end_idx = min(start_idx + max_subplots_per_fig,total_plots) 

135 n_plots = end_idx - start_idx 

136 

137 cols = min(max_cols,n_plots) 

138 rows = math.ceil(n_plots/cols) 

139 max_rows = math.ceil(max_subplots_per_fig/max_cols) 

140 

141 fig = plt.figure(figsize=(fig_size_x,fig_size_y)) 

142 gs = fig.add_gridspec(max_rows, max_cols) 

143 

144 axes = [] 

145 global_head = None 

146 

147 # Create subplots into the GridSpec 

148 for i in range(n_plots): 

149 r = i // cols 

150 c = i % cols 

151 

152 # First Subplot becomes the global head 

153 if global_head is None: 

154 ax = fig.add_subplot(gs[r,c]) 

155 global_head = ax 

156 else: 

157 ax = fig.add_subplot(gs[r,c], sharex=global_head, sharey=global_head) 

158 

159 axes.append(ax) 

160 

161 # Set global axis labels on bottom left subplot 

162 if c == 0: 

163 ax.set_ylabel(ylabel) 

164 ax.tick_params(labelleft=True) 

165 else: 

166 ax.tick_params(labelleft=False) 

167 # ax.set_yticklabels([]) 

168 

169 if r == rows - 1: 

170 ax.set_xlabel(xlabel) 

171 ax.tick_params(labelbottom=True) 

172 else: 

173 ax.tick_params(labelbottom=False) 

174 

175 for i in range(n_plots, max_rows * max_cols): 

176 fig.add_subplot(gs[i // max_cols, i % max_cols]).set_visible(False) 

177 

178 figs.append(fig) 

179 all_axes.append(axes) 

180 

181 start_idx += n_plots 

182 

183 return figs, all_axes 

184 

185def dynamic_barh(values, labels=None, max_per_col=35): 

186 """Create a horizontal bar chart with automatic multi-column layout. 

187 

188 Parameters 

189 ---------- 

190 values : array_like 

191 Numeric values to plot as horizontal bars. 

192 labels : CoordinateArray or None, optional 

193 Labels for the y-axis ticks. If a ``CoordinateArray`` is provided the 

194 tick labels are set accordingly. Default is ``None``. 

195 max_per_col : int, optional 

196 Maximum number of bars per column before a new column is added. 

197 Default is 35. 

198 

199 Returns 

200 ------- 

201 fig : matplotlib.figure.Figure 

202 The created figure. 

203 axes : list of matplotlib.axes.Axes 

204 List of axes, one per column. 

205 """ 

206 n = len(values) 

207 

208 # ---- Determine number of columns ---- 

209 cols = math.ceil(n / max_per_col) 

210 rows = 1 

211 

212 # ---- Split values into chunks for each column ---- 

213 chunks = [ 

214 values[i * max_per_col : (i + 1) * max_per_col] 

215 for i in range(cols) 

216 ] 

217 

218 if isinstance(labels,CoordinateArray): 

219 label_chunks = [ 

220 labels[i * max_per_col : (i + 1) * max_per_col] 

221 for i in range(cols) 

222 ] 

223 else: 

224 label_chunks = [None] * cols 

225 

226 # ---- Automatic figure height based on the tallest column ---- 

227 h_per_bar = 0.35 

228 base_height = 1.0 

229 tallest_col = max(len(chunk) for chunk in chunks) 

230 fig_height = base_height + h_per_bar * tallest_col 

231 

232 # Wider figure for more columns 

233 fig_width = 6 * cols 

234 

235 fig, axes = plt.subplots( 

236 rows, cols, 

237 figsize=(fig_width, fig_height), 

238 sharex=True 

239 ) 

240 

241 if cols == 1: 

242 axes = [axes] 

243 else: 

244 axes = axes.flatten() 

245 

246 # ---- Plot each column ---- 

247 for ax, chunk, lbl_chunk in zip(axes, chunks, label_chunks): 

248 y = range(len(chunk)) 

249 ax.barh(y, chunk,color='deepskyblue',edgecolor='black', linewidth=1) 

250 

251 if isinstance(lbl_chunk,CoordinateArray): 

252 ax.set_yticks(y) 

253 ax.set_yticklabels(lbl_chunk) 

254 else: 

255 ax.set_yticks(y) 

256 

257 ax.invert_yaxis() 

258 ax.margins(y=0) 

259 ax.set_axisbelow(True) 

260 ax.xaxis.grid(True) 

261 

262 # Apply shared y-limits 

263 ax.set_ylim(axes[0].get_ylim()) 

264 

265 # Remove unused axes (shouldn't happen but safe) 

266 for ax in axes[len(chunks):]: 

267 ax.remove() 

268 

269 fig.tight_layout() 

270 return fig, axes 

271 

272def response_outside_mask(response, response_limit, mask_type: str = 'above'): 

273 """Compute a boolean mask of where response is outside the specified limit. 

274 

275 Parameters 

276 ---------- 

277 response : NDDataArray 

278 The measured response data array. 

279 response_limit : NDDataArray 

280 The limit data array to compare against. 

281 mask_type : str, optional 

282 Direction of comparison: 'above' to flag where response exceeds the 

283 limit, 'below' to flag where response falls below the limit. 

284 Default is 'above'. 

285 

286 Returns 

287 ------- 

288 np.ndarray 

289 Boolean array that is True where the response is outside the limit. 

290 

291 Raises 

292 ------ 

293 ValueError 

294 If mask_type is not 'above' or 'below'. 

295 """ 

296 response_in_limit_mask = np.isin(response.abscissa, response_limit.abscissa) 

297 limit_in_response_mask = np.isin(response_limit.abscissa, response.abscissa) 

298 response_exsists_mask = np.invert(np.isnan(response.ordinate)) 

299 limit_exists_mask = np.invert(np.isnan(response_limit.ordinate)) 

300 

301 if mask_type == 'above': 

302 outside_mask = np.logical_and(np.where(response_in_limit_mask, np.abs(np.abs(response.ordinate)), 0) > np.where(limit_in_response_mask, np.abs(np.abs(response_limit.ordinate)), 0), np.where(response_in_limit_mask, np.abs(response.ordinate), 0) > np.where(limit_in_response_mask, np.abs(response_limit.ordinate), 0)) 

303 elif mask_type == 'below': 

304 outside_mask = np.logical_and(np.where(response_in_limit_mask, np.abs(np.abs(response.ordinate)), 0) < np.where(limit_in_response_mask, np.abs(np.abs(response_limit.ordinate)), 0), np.where(response_in_limit_mask, np.abs(response.ordinate), 0) < np.where(limit_in_response_mask, np.abs(response_limit.ordinate), 0)) 

305 else: 

306 raise ValueError(f"mask_type must be 'above' or 'below', got '{mask_type}'") 

307 

308 outside_mask = np.logical_and.reduce((outside_mask, response_in_limit_mask, response_exsists_mask, limit_exists_mask)) 

309 

310 return outside_mask 

311 

312def _shade_out_of_limit(ax, freqs, mask, limit_ordinate, ylim_bound, color, alpha, label): 

313 """Shade from the abort limit curve to the plot edge for out-of-limit regions. 

314 

315 For each contiguous run of True values in *mask*, builds an x array that 

316 extends half the local frequency spacing beyond each edge so that even a 

317 single isolated spectral line produces a visible shaded band. The y extent 

318 runs from the abort limit ordinate (extended at constant value to the padded 

319 edges) to *ylim_bound*, preserving the original fill-between appearance. 

320 Works correctly for non-uniform (e.g. octave-band) frequency spacing. 

321 

322 Parameters 

323 ---------- 

324 ax : matplotlib.axes.Axes 

325 The axes on which to draw the shading. 

326 freqs : np.ndarray, shape (N,) 

327 Frequency abscissa values corresponding to *mask*. 

328 mask : np.ndarray of bool, shape (N,) 

329 True at each frequency line that is outside the limit. 

330 limit_ordinate : np.ndarray, shape (N,) 

331 Abort limit ordinate values at each frequency in *freqs*. 

332 ylim_bound : float 

333 The plot-edge y value to shade toward (``min(ylim)`` for below, 

334 ``max(ylim)`` for above). 

335 color : str 

336 Patch fill color. 

337 alpha : float 

338 Patch transparency. 

339 label : str 

340 Legend label applied to the first patch; subsequent patches use 

341 ``'_nolegend_'`` to avoid duplicate legend entries. 

342 """ 

343 indices = np.where(mask)[0] 

344 if len(indices) == 0: 

345 return 

346 

347 # Split into contiguous groups 

348 breaks = np.where(np.diff(indices) > 1)[0] + 1 

349 groups = np.split(indices, breaks) 

350 

351 labeled = False 

352 for group in groups: 

353 i0, i1 = int(group[0]), int(group[-1]) 

354 

355 # Left edge: midpoint to the previous line; use right-side spacing at boundary 

356 if i0 > 0: 

357 x0 = freqs[i0] - (freqs[i0] - freqs[i0 - 1]) / 2 

358 else: 

359 x0 = freqs[i0] - (freqs[i0 + 1] - freqs[i0]) / 2 if len(freqs) > 1 else freqs[i0] 

360 

361 # Right edge: midpoint to the next line; use left-side spacing at boundary 

362 if i1 < len(freqs) - 1: 

363 x1 = freqs[i1] + (freqs[i1 + 1] - freqs[i1]) / 2 

364 else: 

365 x1 = freqs[i1] + (freqs[i1] - freqs[i1 - 1]) / 2 if len(freqs) > 1 else freqs[i1] 

366 

367 # Build x array with padded endpoints; extend limit ordinate at constant value 

368 x_vals = np.concatenate([[x0], freqs[i0:i1 + 1], [x1]]) 

369 y_limit = np.abs(np.concatenate([[limit_ordinate[i0]], limit_ordinate[i0:i1 + 1], [limit_ordinate[i1]]])) 

370 

371 ax.fill_between(x_vals, y_limit, ylim_bound, color=color, alpha=alpha, 

372 edgecolor='none', label=label if not labeled else '_nolegend_') 

373 labeled = True 

374 

375# class TransientVibTest: 

376 

377# class RandomVibTest: 

378class RandomVibTest: 

379 """Container for a random vibration test data set with automated plotting. 

380 

381 Holds time history data, cross-power spectral densities, specifications, 

382 and data for a random vibration test environment. Provides methods to 

383 compute CPSDs/FRFs and produce a standard suite of diagnostic plots. 

384 

385 Parameters 

386 ---------- 

387 source_data : RattlesnakeRandomEnvironmentData, optional 

388 Parsed Rattlesnake environment data for the test environment. 

389 geometry : str or Geometry, optional 

390 Path to a geometry file or a ``Geometry`` object for 3-D visualization. 

391 coordinate : CoordinateArray, optional 

392 Full channel coordinate array (all channels). 

393 control_coordinate : CoordinateArray, optional 

394 Subset of coordinates for control/reference channels. 

395 response_coordinate : CoordinateArray, optional 

396 Subset of coordinates for response channels. 

397 excitation_coordinate : CoordinateArray, optional 

398 Subset of coordinates for excitation (drive) channels. 

399 time_data : list of TimeHistoryArray, optional 

400 List of time history arrays, one per data set. 

401 cpsd : list of PowerSpectralDensityArray or None, optional 

402 Pre-computed cross-power spectral densities. Pass ``None`` (default) 

403 to have them computed on demand. 

404 specification_cpsd : PowerSpectralDensityArray, optional 

405 Target PSD specification. 

406 specification_warning_psd : PowerSpectralDensityArray, optional 

407 Warning-level tolerance bands around the specification. 

408 specification_abort_psd : PowerSpectralDensityArray, optional 

409 Abort-level tolerance bands around the specification. 

410 averages : int, optional 

411 Number of spectral averages used when computing CPSDs. 

412 overlap : float, optional 

413 Fractional overlap between frames (0 to 1). 

414 fft_lines : int, optional 

415 Number of FFT lines. 

416 window : str, optional 

417 Name of the windowing function (e.g. ``'hann'``). 

418 samples_per_frame : int, optional 

419 Number of samples per FFT frame. 

420 start_time : float or None, optional 

421 Start time (seconds) for the analysis window within each data set. 

422 ``None`` triggers automatic selection via ``optimal_subset``. 

423 units : np.ndarray, optional 

424 String array of engineering-unit labels, one per channel. 

425 frf : list of TransferFunctionArray or None, optional 

426 Pre-computed frequency response functions. 

427 oct_order : int, optional 

428 Octave band order for octave-averaged results. 

429 """ 

430 def __init__(self, 

431 source_data: RattlesnakeRandomEnvironmentData = None, 

432 geometry: str | Geometry = None, 

433 coordinate: CoordinateArray = None, 

434 control_coordinate: CoordinateArray = None, 

435 response_coordinate: CoordinateArray = None, 

436 excitation_coordinate: CoordinateArray = None, 

437 time_data: list[TimeHistoryArray] = None, 

438 cpsd: list[PowerSpectralDensityArray] = None, 

439 specification_cpsd: PowerSpectralDensityArray = None, 

440 specification_warning_psd: PowerSpectralDensityArray = None, 

441 specification_abort_psd: PowerSpectralDensityArray = None, 

442 averages: int = None, 

443 overlap: float = None, 

444 fft_lines: int = None, 

445 window: str = None, 

446 samples_per_frame: int = None, 

447 start_time: float = None, 

448 units: np.ndarray = None, 

449 frf: list[TransferFunctionArray] = None, 

450 oct_order: int = None, 

451 ): 

452 """Store all test data attributes. 

453 

454 Every named parameter is assigned directly to ``self``. See the class 

455 docstring for parameter descriptions. ``cpsd`` and ``frf`` default to 

456 empty lists when ``None`` is passed, and the specification CPSD is 

457 sanitised so that zero values are replaced with ``NaN``. 

458 """ 

459 self.source_data = source_data 

460 self._geometry = None 

461 self.geometry = geometry 

462 self.coordinate = coordinate 

463 self.control_coordinate = control_coordinate 

464 self.response_coordinate = response_coordinate 

465 self.excitation_coordinate = excitation_coordinate 

466 self._time_data_truncated = None 

467 self._time_data = None 

468 self._start_time = None 

469 self.time_data = time_data 

470 self.start_time = start_time 

471 self.cpsd = cpsd if cpsd is not None else [] 

472 self.specification_cpsd = specification_cpsd 

473 self.specification_warning_psd = specification_warning_psd 

474 self.specification_abort_psd = specification_abort_psd 

475 self.averages = averages 

476 self.overlap = overlap 

477 self.fft_lines = fft_lines 

478 self.window = window 

479 self.samples_per_frame = samples_per_frame 

480 self.units = units 

481 self.frf = frf if frf is not None else [] 

482 self.oct_order = oct_order 

483 

484 # Sanitize the specification so 0 values go to NaN 

485 if self.specification_cpsd is not None: 

486 zero_mask = np.abs(self.specification_cpsd.ordinate)==0 

487 self.specification_cpsd.ordinate[zero_mask] = np.nan 

488 

489 @property 

490 def geometry(self): 

491 """Geometry object for 3-D visualisation. 

492 

493 Returns 

494 ------- 

495 Geometry or None 

496 The loaded geometry object, or ``None`` if none has been set. 

497 """ 

498 return self._geometry 

499 

500 @geometry.setter 

501 def geometry(self, arg): 

502 """Set the geometry from a file path, ``Geometry`` object, or ``None``. 

503 

504 Parameters 

505 ---------- 

506 arg : str, Geometry, or None 

507 If a string, the geometry is loaded from that file path. If a 

508 ``Geometry`` instance it is stored directly. Any other value 

509 (including ``None``) is stored as-is. 

510 """ 

511 if isinstance(arg, str): 

512 self._geometry = Geometry.load(arg) 

513 elif isinstance(arg, Geometry): 

514 self._geometry = arg 

515 else: 

516 self._geometry = arg 

517 

518 @property 

519 def time_data(self): 

520 """List of full-length ``TimeHistoryArray`` objects, one per data set. 

521 

522 Returns 

523 ------- 

524 list of TimeHistoryArray or None 

525 """ 

526 return self._time_data 

527 

528 @time_data.setter 

529 def time_data(self, value): 

530 """Set time data and invalidate any cached truncated arrays. 

531 

532 Parameters 

533 ---------- 

534 value : list of TimeHistoryArray or None 

535 New time data list. 

536 """ 

537 self._time_data = value 

538 self._time_data_truncated = None 

539 

540 @property 

541 def start_time(self): 

542 """List of analysis-window start times in seconds, one per data set. 

543 

544 A value of ``None`` for any element triggers automatic selection via 

545 :func:`optimal_subset` when the truncated data is first requested. 

546 

547 Returns 

548 ------- 

549 list of float or None 

550 """ 

551 return self._start_time 

552 

553 @start_time.setter 

554 def start_time(self, value): 

555 """Set start times and invalidate any cached truncated arrays. 

556 

557 Parameters 

558 ---------- 

559 value : list of float or None 

560 New start-time list. 

561 """ 

562 self._start_time = value 

563 self._time_data_truncated = None 

564 

565 @property 

566 def time_data_truncated(self): 

567 """Time data windowed to the analysis subset used for CPSD computation. 

568 

569 Lazily computed on first access and cached. Each element is extracted 

570 from the corresponding full ``TimeHistoryArray`` beginning at 

571 ``start_time`` and covering exactly 

572 ``samples_per_frame * averages - overlap_samples * (averages - 1)`` 

573 samples. When ``start_time`` is ``None`` for a data set, the optimal 

574 start index is found via :func:`optimal_subset` and the corresponding 

575 ``start_time`` entry is updated in-place. 

576 

577 Returns 

578 ------- 

579 list of TimeHistoryArray 

580 One truncated ``TimeHistoryArray`` per data set. 

581 """ 

582 if self._time_data_truncated is None: 

583 result = [] 

584 if self.start_time is None: 

585 self.start_time = [None]*len(self.time_data) 

586 for index, (start_time, time_data) in enumerate(zip(self.start_time, self.time_data)): 

587 avg_dt_per_channel = np.mean(np.diff(time_data.abscissa, axis=1), axis=1) 

588 dt = np.mean(avg_dt_per_channel) 

589 samples_overlap = int(self.samples_per_frame * self.overlap) 

590 samples_total = self.samples_per_frame * self.averages - samples_overlap * (self.averages - 1) 

591 if start_time is None: 

592 start_index = optimal_subset(time_data, samples_total) 

593 self._start_time[index] = start_index * dt 

594 result.append(time_data.extract_elements_by_abscissa(self._start_time[index], self._start_time[index] + samples_total * dt)) 

595 self._time_data_truncated = result 

596 return self._time_data_truncated 

597 

598 def set_tolerance_limit_psd(self, dB=6): 

599 """Set the abort-tolerance PSD from the specification using a dB tolerance. 

600 

601 Computes lower and upper bounds as 

602 ``specification_asd / 10^(dB/10)`` and 

603 ``specification_asd * 10^(dB/10)`` respectively, then stores them 

604 as ``self.specification_abort_psd``. Only the abort band is updated; 

605 ``specification_warning_psd`` is not modified. 

606 

607 Parameters 

608 ---------- 

609 dB : float, optional 

610 Tolerance in decibels above and below the specification. 

611 Default is 6 dB. 

612 

613 Raises 

614 ------ 

615 TypeError 

616 If ``specification_cpsd`` is not a ``PowerSpectralDensityArray``. 

617 """ 

618 if isinstance(self.specification_cpsd, PowerSpectralDensityArray): 

619 lower_limit = self.specification_cpsd.get_asd() / 10**(dB / 10) 

620 upper_limit = self.specification_cpsd.get_asd() * 10**(dB / 10) 

621 self.specification_abort_psd = np.concatenate((lower_limit[np.newaxis, :], upper_limit[np.newaxis, :])) 

622 else: 

623 raise TypeError("specification_cpsd must be a PowerSpectralDensityArray to set tolerance limits") 

624 

625 def compute_cpsd(self): 

626 """Compute cross-power spectral densities for each time data set. 

627 

628 Computes ASDs for each truncated time data segment and appends them to 

629 ``self.cpsd``. Already-computed entries (``PowerSpectralDensityArray`` 

630 instances) are left unchanged; only ``None`` or non-array entries are 

631 recomputed. 

632 """ 

633 # Ensure the list is long enough 

634 while len(self.cpsd) < len(self.time_data): 

635 self.cpsd.append(None) 

636 

637 for index, time_data_truncated in enumerate(self.time_data_truncated): 

638 if not isinstance(self.cpsd[index], PowerSpectralDensityArray): 

639 self.cpsd[index] = time_data_truncated.cpsd( 

640 samples_per_frame=self.samples_per_frame, 

641 overlap=self.overlap, 

642 window=self.window.lower(), 

643 averages_to_keep=self.averages, 

644 only_asds=True 

645 ) 

646 

647 def plot_cpsd_time_subset(self, save_path=None): 

648 """Plot the selected time subset used for CPSD computation. 

649 

650 Parameters 

651 ---------- 

652 save_path : str or None, optional 

653 If provided, the figure is saved to this path. If there are 

654 multiple time data sets the data set number is appended to the 

655 filename stem. Default is ``None`` (figure is not saved). 

656 """ 

657 for index in range(len(self.time_data)): 

658 with plt.rc_context({'path.simplify': True, 'path.simplify_threshold': 1.0, 

659 'agg.path.chunksize': 10000}): 

660 fig, ax = plt.subplots(figsize=(12, 6)) 

661 ordinate_rms = np.sqrt(np.mean(self.time_data[index].ordinate**2, axis=0)) 

662 ordinate_rms_truncated = np.sqrt(np.mean(self.time_data_truncated[index].ordinate**2, axis=0)) 

663 ax.plot(self.time_data[index][0].abscissa, ordinate_rms, label='Full Time Set', color='darkgrey') 

664 ax.plot(self.time_data_truncated[index][0].abscissa, ordinate_rms_truncated, label='Selected Time Subset', color='deepskyblue') 

665 ax.set_title('Subsection of Time Data') 

666 ax.set_xlabel('Time (s)') 

667 ax.set_ylabel('RMS Amplitude Across All Channels') 

668 ax.legend() 

669 plt.tight_layout() 

670 

671 if save_path is not None: 

672 if not os.path.exists(os.path.dirname(save_path)): 

673 os.makedirs(os.path.dirname(save_path)) 

674 if len(self.time_data) == 1: 

675 save_name = save_path 

676 else: 

677 save_name = os.path.splitext(save_path)[0] + ' (Data Set ' + str(index + 1) + ')' + os.path.splitext(save_path)[-1] 

678 fig.savefig(save_name, dpi=600, bbox_inches='tight') 

679 

680 def compute_frf(self): 

681 """Compute FRFs for each time data set and append them to ``self.frf``. 

682 

683 For each truncated time data segment, computes H1 frequency response 

684 functions between the excitation (reference) channels and all response 

685 channels using the same windowing parameters as CPSD computation. 

686 Each resulting ``TransferFunctionArray`` is appended to ``self.frf``. 

687 

688 Excitation channels are identified by a non-empty ``feedback_channel`` 

689 entry in the source channel table, matching the logic used in 

690 ``RattlesnakeData.excitation_coordinate``. 

691 """ 

692 for index, time_data_truncated in enumerate(self.time_data_truncated): 

693 reference_time_data = time_data_truncated[self.excitation_coordinate[:, np.newaxis]] 

694 response_time_data = time_data_truncated[self.response_coordinate[:, np.newaxis]] 

695 self.frf.append(sdpy.data.frf_from_time_data( 

696 reference_data=reference_time_data, 

697 response_data=response_time_data, 

698 samples_per_average=self.samples_per_frame, 

699 overlap=self.overlap, 

700 method='H1', 

701 window=self.window.lower() 

702 )) 

703 

704 def plot_control_vs_spec(self, save_path: None|str = None): 

705 """Plot measured control PSD against the specification for each control channel. 

706 

707 For each data set, creates a grid of subplots — one per control channel 

708 — showing the abort-limit band (filled grey), warning-limit band 

709 (light grey), specification (dashed black), and measured response 

710 (blue). Out-of-limit regions are shaded and a percentage-out-of-limit 

711 annotation is added to each subplot. The in-band RMS level is shown 

712 as a text annotation. 

713 

714 Parameters 

715 ---------- 

716 save_path : str or None, optional 

717 Base file path for saving figures. The figure index and (when 

718 multiple data sets exist) data set index are appended to the stem. 

719 Default is ``None`` (figures are not saved). 

720 

721 Returns 

722 ------- 

723 figures : list of list of matplotlib.figure.Figure 

724 ``figures[i]`` is the list of figures for data set *i*. 

725 axes : list of list of list of matplotlib.axes.Axes 

726 ``axes[i]`` is the list of per-figure axes groups for data set *i*. 

727 ``axes[i][j]`` is the list of ``Axes`` objects on figure *j*. 

728 """ 

729 figures = [None]*len(self.time_data) 

730 axes = [None]*len(self.time_data) 

731 

732 # If the CPSD is not defined, compute it automatically 

733 if any(not isinstance(cpsd, PowerSpectralDensityArray) for cpsd in self.cpsd): 

734 self.compute_cpsd() 

735 

736 control_coordinates = np.concatenate((self.control_coordinate[:,np.newaxis],self.control_coordinate[:,np.newaxis]),axis=1) 

737 for index,cpsd in enumerate(self.cpsd): 

738 xlim = NDDataArray.get_abscissa_limits([cpsd[control_coordinates],self.specification_cpsd.get_drive_points(),self.specification_warning_psd,self.specification_abort_psd]) 

739 ylim = NDDataArray.get_ordinate_limits([cpsd[control_coordinates],self.specification_cpsd.get_drive_points(),self.specification_warning_psd,self.specification_abort_psd],xlim) 

740 

741 np.max(np.abs(cpsd[control_coordinates].extract_elements_by_abscissa(min(xlim),max(xlim)).ordinate)) 

742 

743 # Plot CPSD Results for Control Channels 

744 all_figs, all_axes = make_multi_figures(len(self.control_coordinate)) 

745 

746 coordinate_index = 0 

747 figure_index = 0 

748 for fig, axes_group in zip(all_figs,all_axes): 

749 for ax in axes_group: 

750 coordinate = self.control_coordinate[coordinate_index] 

751 handles = [] 

752 labels = [] 

753 

754 # Plot Abort Limit 

755 if isinstance(self.specification_abort_psd,PowerSpectralDensityArray) and np.any(~np.isnan(self.specification_abort_psd.ordinate)): 

756 abscissa = self.specification_abort_psd[0][coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).abscissa 

757 ordinate1 = self.specification_abort_psd[-1][coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).ordinate 

758 ordinate2 = self.specification_abort_psd[-0][coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).ordinate 

759 ax.fill_between(abscissa,np.abs(ordinate1),np.abs(ordinate2), alpha=1, facecolor='lightgrey',edgecolor='darkgrey',linewidth=1) 

760 ax.collections[-1].set_label('Abort Limit') 

761 

762 # Plot Warning Limit 

763 if isinstance(self.specification_warning_psd,PowerSpectralDensityArray) and np.any(~np.isnan(self.specification_warning_psd.ordinate)): 

764 abscissa = self.specification_warning_psd[0][coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).abscissa 

765 ordinate1 = self.specification_warning_psd[-1][coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).ordinate 

766 ordinate2 = self.specification_warning_psd[-0][coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).ordinate 

767 ax.fill_between(abscissa,np.abs(ordinate1),np.abs(ordinate2), alpha=0.2, facecolor='lightgrey',edgecolor='darkgrey',linewidth=1) 

768 ax.collections[-1].set_label('Warning Limit') 

769 

770 # Plot Specification 

771 if isinstance(self.specification_cpsd,PowerSpectralDensityArray) and np.any(~np.isnan(self.specification_cpsd.ordinate)): 

772 abscissa = self.specification_cpsd[coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).abscissa 

773 ordinate = self.specification_cpsd[coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).ordinate 

774 ax.plot(abscissa,np.abs(ordinate),color='black',linestyle='--',linewidth=1) 

775 ax.get_lines()[-1].set_label('Specification') 

776 

777 # Plot Control 

778 if isinstance(cpsd,PowerSpectralDensityArray): 

779 abscissa = cpsd[coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).abscissa 

780 ordinate = cpsd[coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).ordinate 

781 ax.plot(abscissa,np.abs(ordinate),color='deepskyblue',linewidth=1) 

782 ax.get_lines()[-1].set_label('Control') 

783 

784 # Shade Area Outside the Abort Limit if Response Exceeds the Abort Limit 

785 if isinstance(cpsd,PowerSpectralDensityArray) and isinstance(self.specification_abort_psd,PowerSpectralDensityArray) and np.any(~np.isnan(self.specification_abort_psd.ordinate)): 

786 response_too_low_mask = response_outside_mask(cpsd[coordinate],self.specification_abort_psd[0][coordinate],mask_type='below') 

787 response_too_high_mask = response_outside_mask(cpsd[coordinate],self.specification_abort_psd[1][coordinate],mask_type='above') 

788 

789 _shade_out_of_limit(ax, cpsd[coordinate].abscissa, response_too_low_mask, 

790 self.specification_abort_psd[0][coordinate].ordinate, min(ylim), 

791 color='blue', alpha=0.2, label='Response Below Tolerance') 

792 _shade_out_of_limit(ax, cpsd[coordinate].abscissa, response_too_high_mask, 

793 self.specification_abort_psd[-1][coordinate].ordinate, max(ylim), 

794 color='red', alpha=0.2, label='Response Above Tolerance') 

795 

796 response_inside_bandwidth_mask = np.logical_and(cpsd[coordinate].abscissa>=min(xlim),cpsd[coordinate].abscissa<=max(xlim)) 

797 response_outside_tolerance_mask = np.logical_or(response_too_low_mask,response_too_high_mask) 

798 response_inside_bandwith_and_outside_tolerance = np.logical_and(response_inside_bandwidth_mask,response_outside_tolerance_mask) 

799 percent_out = sum(response_inside_bandwith_and_outside_tolerance)/sum(response_inside_bandwidth_mask)*100 

800 

801 ax.text(0.99, 0.01, 'Out: ' + str(percent_out.round(2)) + '%',transform=ax.transAxes,ha="right", va="bottom", fontsize=8) 

802 

803 # Compute RMS Level of Response within the x-limits 

804 rms_level = cpsd[coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).rms(self.oct_order) 

805 

806 # Set Plot Properties 

807 ax.set_yscale('log') 

808 

809 ax.set_title(str(coordinate)) 

810 units = self.units[np.intersect1d(self.coordinate,coordinate,return_indices=True)[1]][0] 

811 ax.text(0.01, 0.01, 'RMS: ' + str(rms_level.round(2)) + ' ' + units,transform=ax.transAxes,ha="left", va="bottom", fontsize=8) 

812 ax.set_xlim(min(xlim),max(xlim)) 

813 ax.set_ylim(min(ylim),max(ylim)) 

814 ax.set_axisbelow(True) 

815 

816 # ax.xaxis.set_minor_locator(LogLocator(base=10.0, subs='auto', numticks=12)) 

817 ax.yaxis.set_minor_locator(LogLocator(base=10.0, subs='auto', numticks=12)) 

818 ax.grid(True,which='major',ls='-',color='grey',zorder=0, linewidth=0.2) 

819 ax.grid(True,which='minor',ls=':',color='grey',zorder=0, linewidth=0.2) 

820 

821 coordinate_index += 1 

822 

823 h, l = ax.get_legend_handles_labels() 

824 handles.extend(h) 

825 labels.extend(l) 

826 

827 unique = dict(zip(labels,handles)) 

828 

829 fig.legend(unique.values(),unique.keys(),loc='lower center',ncol=min([len(unique),3])) 

830 fig.suptitle('Control vs. Specification PSD') 

831 

832 # Save Picture 

833 if save_path is not None: 

834 if not os.path.exists(os.path.dirname(save_path)): 

835 os.makedirs(os.path.dirname(save_path)) 

836 if len(self.time_data) == 1: 

837 save_name = os.path.splitext(save_path)[0] + ' (Figure ' + str(figure_index + 1) + ')' + os.path.splitext(save_path)[-1] 

838 else: 

839 save_name = os.path.splitext(save_path)[0] + ' (Data Set ' + str(index + 1) + ', Figure ' + str(figure_index + 1) + ')' + os.path.splitext(save_path)[-1] 

840 fig.savefig(save_name,dpi=600 ,bbox_inches='tight') 

841 

842 figure_index += 1 

843 figures[index] = all_figs 

844 axes[index] = all_axes 

845 return figures, axes 

846 

847 def plot_response(self, save_path: None|str = None, one_figure=False): 

848 """Plot the measured response PSD for each response channel. 

849 

850 For each data set, creates a grid of subplots — one per response 

851 channel — showing the measured ASD (blue) on a log scale. The 

852 in-band RMS level is annotated on each subplot. 

853 

854 Parameters 

855 ---------- 

856 save_path : str or None, optional 

857 Base file path for saving figures. The figure index and (when 

858 multiple data sets exist) data set index are appended to the stem. 

859 Default is ``None`` (figures are not saved). 

860 

861 one_figure : bool, optional 

862 Whether successive datasets should be overlayed on the same grid of  

863 figures, or new grids should be made for each dataset 

864 Default is `False` 

865 

866 Returns 

867 ------- 

868 figures : list of list of matplotlib.figure.Figure 

869 ``figures[i]`` is the list of figures for data set *i*. 

870 axes : list of list of list of matplotlib.axes.Axes 

871 ``axes[i]`` is the nested list of axis groups for data set *i*. 

872 """ 

873 figures = [None]*len(self.time_data) 

874 axes = [None]*len(self.time_data) 

875 

876 # If the CPSD is not defined, compute it automatically 

877 if all(not isinstance(cpsd, PowerSpectralDensityArray) for cpsd in self.cpsd): 

878 self.compute_cpsd() 

879 

880 response_coordinates = np.concatenate((self.response_coordinate[:,np.newaxis],self.response_coordinate[:,np.newaxis]),axis=1) 

881 for index,cpsd in enumerate(self.cpsd): 

882 xlim = NDDataArray.get_abscissa_limits(cpsd[response_coordinates]) 

883 ylim = NDDataArray.get_ordinate_limits(cpsd[response_coordinates],xlim) 

884 

885 # Plot CPSD Results for Response Channels 

886 if (index == 0 and one_figure) or (not one_figure): 

887 all_figs, all_axes = make_multi_figures(len(self.response_coordinate)) 

888 

889 coordinate_index = 0 

890 figure_index = 0 

891 for fig, axes_group in zip(all_figs,all_axes): 

892 for ax in axes_group: 

893 coordinate = self.response_coordinate[coordinate_index] 

894 # for fig in all_figs: 

895 handles = [] 

896 labels = [] 

897 

898 # Plot Response 

899 if isinstance(cpsd,PowerSpectralDensityArray): 

900 abscissa = cpsd[coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).abscissa 

901 ordinate = cpsd[coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).ordinate 

902 ax.plot(abscissa,np.abs(ordinate),color=None if one_figure else 'deepskyblue',linewidth=1) 

903 

904 # Compute RMS Level of Response within the x-limits 

905 rms_level = cpsd[coordinate].extract_elements_by_abscissa(min(xlim),max(xlim)).rms(self.oct_order) 

906 

907 # Set Plot Properties 

908 ax.set_yscale('log') 

909 ax.set_title(str(coordinate)) 

910 units = self.units[np.intersect1d(self.coordinate,coordinate,return_indices=True)[1]][0] 

911 if not one_figure: 

912 ax.text(0.01, 0.01, 'RMS: ' + str(rms_level.round(2)) + ' ' + units,transform=ax.transAxes,ha="left", va="bottom") 

913 

914 ax.set_xlim(min(xlim),max(xlim)) 

915 ax.set_ylim(min(ylim),max(ylim)) 

916 ax.set_axisbelow(True) 

917 ax.yaxis.set_minor_locator(LogLocator(base=10.0, subs='auto', numticks=12)) 

918 ax.grid(True,which='major',ls='-',color='grey',zorder=0, linewidth=0.2) 

919 ax.grid(True,which='minor',ls=':',color='grey',zorder=0, linewidth=0.2) 

920 

921 coordinate_index += 1 

922 

923 fig.suptitle('Response PSD') 

924 

925 # Save Picture 

926 if save_path is not None: 

927 if (one_figure and index==len(self.cpsd)-1) or (not one_figure): 

928 if not os.path.exists(os.path.dirname(save_path)): 

929 os.makedirs(os.path.dirname(save_path)) 

930 if len(self.time_data) == 1 or one_figure: 

931 save_name = os.path.splitext(save_path)[0] + ' (Figure ' + str(figure_index + 1) + ')' + os.path.splitext(save_path)[-1] 

932 else: 

933 save_name = os.path.splitext(save_path)[0] + ' (Data Set ' + str(index + 1) + ', Figure ' + str(figure_index + 1) + ')' + os.path.splitext(save_path)[-1] 

934 fig.savefig(save_name,dpi=600 ,bbox_inches='tight') 

935 

936 figure_index += 1 

937 figures[index] = all_figs 

938 axes[index] = all_axes 

939 return figures, axes 

940 

941 def plot_percent_lines_out(self, save_path: None|str = None): 

942 """Plot the percentage of spectral lines out of tolerance for each control channel. 

943 

944 For each data set, computes the fraction of frequency lines within the 

945 analysis bandwidth where the measured response falls outside the abort 

946 tolerance limits, then displays the result as a horizontal bar chart. 

947 Bars exceeding a 10 % threshold are coloured red. The fraction of 

948 channels that exceed the threshold is shown in the figure title. 

949 

950 Parameters 

951 ---------- 

952 save_path : str or None, optional 

953 Base file path for saving figures. The data set index is appended 

954 to the stem when multiple data sets exist. Default is ``None`` 

955 (figures are not saved). 

956 

957 Returns 

958 ------- 

959 figures : list of matplotlib.figure.Figure 

960 One figure per data set. 

961 axes : list of list of matplotlib.axes.Axes 

962 One list of axes per data set. 

963 """ 

964 figures = [None]*len(self.time_data) 

965 axes = [None]*len(self.time_data) 

966 

967 # If the CPSD is not defined, compute it automatically 

968 if all(not isinstance(cpsd, PowerSpectralDensityArray) for cpsd in self.cpsd): 

969 self.compute_cpsd() 

970 

971 control_coordinates = np.concatenate((self.control_coordinate[:,np.newaxis],self.control_coordinate[:,np.newaxis]),axis=1) 

972 

973 for index,cpsd in enumerate(self.cpsd): 

974 xlim = NDDataArray.get_abscissa_limits([cpsd[control_coordinates],self.specification_cpsd.get_drive_points(),self.specification_warning_psd,self.specification_abort_psd]) 

975 

976 # Plot Percent Lines Out 

977 response_too_low_mask = response_outside_mask(cpsd[control_coordinates],self.specification_abort_psd[0][control_coordinates],mask_type='below') 

978 response_too_high_mask = response_outside_mask(cpsd[control_coordinates],self.specification_abort_psd[1][control_coordinates],mask_type='above') 

979 

980 response_inside_bandwidth_mask = np.logical_and(cpsd[control_coordinates].abscissa>=min(xlim),cpsd[control_coordinates].abscissa<=max(xlim)) 

981 response_outside_tolerance_mask = np.logical_or(response_too_low_mask,response_too_high_mask) 

982 response_inside_bandwith_and_outside_tolerance = np.logical_and(response_inside_bandwidth_mask,response_outside_tolerance_mask) 

983 percent_out = sum(response_inside_bandwith_and_outside_tolerance.T)/sum(response_inside_bandwidth_mask.T)*100 

984 

985 threshold = 10 

986 

987 percent_channels_out = sum(percent_out>threshold)/len(percent_out)*100 

988 

989 fig,axes_group = dynamic_barh(values=percent_out,labels=self.control_coordinate) 

990 for ax in axes_group: 

991 bars = ax.patches 

992 for bar in bars: 

993 if bar.get_width() > threshold: 

994 bar.set_facecolor('red') 

995 ax.axvspan(0, threshold, alpha=0.6, facecolor='lightgrey',edgecolor='darkgrey',linewidth=1,zorder=0) 

996 ax.set_xlabel('Percent Lines Out') 

997 fig.suptitle('Percent Lines Out (' + str(percent_channels_out.round(2)) + r'% of Channels)') 

998 fig.tight_layout() 

999 

1000 # Save Picture 

1001 if save_path is not None: 

1002 if not os.path.exists(os.path.dirname(save_path)): 

1003 os.makedirs(os.path.dirname(save_path)) 

1004 if len(self.time_data) == 1: 

1005 save_name = save_path 

1006 else: 

1007 save_name = os.path.splitext(save_path)[0] + ' (Data Set ' + str(index + 1) + ')' + os.path.splitext(save_path)[-1] 

1008 fig.savefig(save_name,dpi=600 ,bbox_inches='tight') 

1009 

1010 figures[index] = fig 

1011 axes[index] = axes_group 

1012 return figures, axes 

1013 

1014 def plot_rms_level(self, save_path: None|str = None): 

1015 """Plot the broadband RMS level for each response channel. 

1016 

1017 RMS values are computed from the CPSD restricted to the analysis 

1018 bandwidth defined by the specification, warning, and abort PSDs. 

1019 Results are displayed as a horizontal bar chart. 

1020 

1021 Parameters 

1022 ---------- 

1023 save_path : str or None, optional 

1024 Base file path for saving figures. The data set index is appended 

1025 to the stem when multiple data sets exist. Default is ``None`` 

1026 (figures are not saved). 

1027 

1028 Returns 

1029 ------- 

1030 figures : list of matplotlib.figure.Figure 

1031 One figure per data set. 

1032 axes : list of list of matplotlib.axes.Axes 

1033 One list of axes per data set. 

1034 """ 

1035 figures = [None]*len(self.time_data) 

1036 axes = [None]*len(self.time_data) 

1037 

1038 # If the CPSD is not defined, compute it automatically 

1039 if all(not isinstance(cpsd, PowerSpectralDensityArray) for cpsd in self.cpsd): 

1040 self.compute_cpsd() 

1041 

1042 response_coordinates = np.concatenate((self.response_coordinate[:,np.newaxis],self.response_coordinate[:,np.newaxis]),axis=1) 

1043 for index,cpsd in enumerate(self.cpsd): 

1044 xlim = NDDataArray.get_abscissa_limits([cpsd,self.specification_cpsd.get_drive_points(),self.specification_warning_psd,self.specification_abort_psd]) 

1045 

1046 # Get Response Response Level 

1047 rms_response = cpsd[response_coordinates].extract_elements_by_abscissa(min(xlim),max(xlim)).rms(oct_order=self.oct_order) 

1048 

1049 # Plot RMS Response Level 

1050 fig,axis = dynamic_barh(values=rms_response,labels=self.response_coordinate) 

1051 

1052 for ax in axis: 

1053 ax.set_xlabel('RMS Level (EU)') 

1054 fig.suptitle('RMS Response Level') 

1055 fig.tight_layout() 

1056 

1057 # Save Picture 

1058 if save_path is not None: 

1059 if not os.path.exists(os.path.dirname(save_path)): 

1060 os.makedirs(os.path.dirname(save_path)) 

1061 if len(self.time_data) == 1: 

1062 save_name = save_path 

1063 else: 

1064 save_name = os.path.splitext(save_path)[0] + ' (Data Set ' + str(index + 1) + ')' + os.path.splitext(save_path)[-1] 

1065 fig.savefig(save_name,dpi=600 ,bbox_inches='tight') 

1066 

1067 figures[index] = fig 

1068 axes[index] = axis 

1069 return figures,axes 

1070 

1071 def plot_rms_error(self, save_path: None|str = None): 

1072 """Plot the broadband RMS error between response and specification for each control channel. 

1073 

1074 The RMS error is computed as the difference between the measured RMS 

1075 level and the specification RMS level, both restricted to the analysis 

1076 bandwidth. Results are displayed as a horizontal bar chart. 

1077 

1078 Parameters 

1079 ---------- 

1080 save_path : str or None, optional 

1081 Base file path for saving figures. The data set index is appended 

1082 to the stem when multiple data sets exist. Default is ``None`` 

1083 (figures are not saved). 

1084 

1085 Returns 

1086 ------- 

1087 figures : list of matplotlib.figure.Figure 

1088 One figure per data set. 

1089 axes : list of list of matplotlib.axes.Axes 

1090 One list of axes per data set. 

1091 """ 

1092 figures = [None]*len(self.time_data) 

1093 axes = [None]*len(self.time_data) 

1094 

1095 control_coordinates = np.concatenate((self.control_coordinate[:,np.newaxis],self.control_coordinate[:,np.newaxis]),axis=1) 

1096 

1097 # If the CPSD is not defined, compute it automatically 

1098 if all(not isinstance(cpsd, PowerSpectralDensityArray) for cpsd in self.cpsd): 

1099 self.compute_cpsd() 

1100 

1101 for index,cpsd in enumerate(self.cpsd): 

1102 xlim = NDDataArray.get_abscissa_limits([cpsd[control_coordinates],self.specification_cpsd.get_drive_points(),self.specification_warning_psd,self.specification_abort_psd]) 

1103 

1104 # Get Response PSDs 

1105 response_psds = cpsd[control_coordinates].extract_elements_by_abscissa(min(xlim),max(xlim)) 

1106 specification_psds = self.specification_cpsd[control_coordinates].extract_elements_by_abscissa(min(xlim),max(xlim)) 

1107 

1108 # Integrate the PSDs over the frequency band to get power in EU 

1109 rms_response = response_psds.rms(self.oct_order) 

1110 rms_spec = specification_psds.rms(self.oct_order) 

1111 

1112 # Compute the RMS error between the two signals 

1113 rms_error = rms_response - rms_spec 

1114 

1115 # Plot RMS Error 

1116 fig,axis = dynamic_barh(values=rms_error,labels=self.control_coordinate) 

1117 

1118 for ax in axis: 

1119 ax.set_xlabel('RMS Error (EU)') 

1120 fig.suptitle('RMS Error Per Channel') 

1121 fig.tight_layout() 

1122 

1123 # Save Picture 

1124 if save_path is not None: 

1125 if not os.path.exists(os.path.dirname(save_path)): 

1126 os.makedirs(os.path.dirname(save_path)) 

1127 if len(self.time_data) == 1: 

1128 save_name = save_path 

1129 else: 

1130 save_name = os.path.splitext(save_path)[0] + ' (Data Set ' + str(index + 1) + ')' + os.path.splitext(save_path)[-1] 

1131 fig.savefig(save_name,dpi=600 ,bbox_inches='tight') 

1132 

1133 figures[index] = fig 

1134 axes[index] = axis 

1135 return figures,axes 

1136 

1137 def plot_kurtosis(self, save_path: None|str = None): 

1138 """Plot the kurtosis of each response channel time history. 

1139 

1140 Kurtosis is computed on the truncated (analysis-window) portion of 

1141 each data set using Fisher's definition disabled (Pearson kurtosis, 

1142 where Gaussian has kurtosis = 3). Bars that fall outside the range 

1143 ``[3 - 1, 3 + 1]`` are coloured red. 

1144 

1145 Parameters 

1146 ---------- 

1147 save_path : str or None, optional 

1148 Base file path for saving figures. The data set index is appended 

1149 to the stem when multiple data sets exist. Default is ``None`` 

1150 (figures are not saved). 

1151 

1152 Returns 

1153 ------- 

1154 figures : list of matplotlib.figure.Figure 

1155 One figure per data set. 

1156 axes : list of list of matplotlib.axes.Axes 

1157 One list of axes per data set. 

1158 """ 

1159 figures = [None]*len(self.time_data) 

1160 axes = [None]*len(self.time_data) 

1161 for index,time_data_truncated in enumerate(self.time_data_truncated): 

1162 # Remove Excitation Coordinate Data to leave just response Data 

1163 control_indices = time_data_truncated.response_coordinate.find_indices(self.excitation_coordinate)[0][0] 

1164 time_data_truncated_indices = np.arange(len(time_data_truncated.response_coordinate)) 

1165 response_time_data = time_data_truncated[np.setdiff1d(time_data_truncated_indices,control_indices)] 

1166 

1167 # Plot Kurtosis 

1168 response_kurtosis = kurtosis(response_time_data.ordinate.T,fisher=False,bias=True) 

1169 

1170 fig,axis = dynamic_barh(values=response_kurtosis,labels=self.response_coordinate) 

1171 

1172 figures[index] = fig 

1173 axes[index] = axis 

1174 

1175 tolerance = 1 

1176 

1177 for ax in axis: 

1178 bars = ax.patches 

1179 for bar in bars: 

1180 if bar.get_width() > 3+tolerance or bar.get_width() < 3-tolerance: 

1181 bar.set_facecolor('red') 

1182 ax.axvspan(3-tolerance, 3+tolerance, alpha=0.6, facecolor='lightgrey',edgecolor='darkgrey',linewidth=1,zorder=0) 

1183 ax.set_xlabel('Kurtosis') 

1184 ax.set_xlim([0,3+tolerance]) 

1185 fig.suptitle('Kurtosis') 

1186 fig.tight_layout() 

1187 

1188 # Save Picture 

1189 if save_path is not None: 

1190 if not os.path.exists(os.path.dirname(save_path)): 

1191 os.makedirs(os.path.dirname(save_path)) 

1192 if len(self.time_data) == 1: 

1193 save_name = save_path 

1194 else: 

1195 save_name = os.path.splitext(save_path)[0] + ' (Data Set ' + str(index + 1) + ')' + os.path.splitext(save_path)[-1] 

1196 fig.savefig(save_name,dpi=600 ,bbox_inches='tight') 

1197 

1198 return figures,axes 

1199 

1200 def plot_signal_to_noise(self, save_path: None|str = None): 

1201 """Plot the signal-to-noise ratio for a two-dataset (noise + signal) test. 

1202 

1203 Requires exactly two data sets: the first is treated as the noise 

1204 measurement and the second as the signal measurement. SNR is computed 

1205 as ``10 * log10(signal_power / noise_power)`` where power is the 

1206 integral of the ASD over the full frequency range. Bars with SNR 

1207 below 10 dB are coloured red. 

1208 

1209 Parameters 

1210 ---------- 

1211 save_path : str or None, optional 

1212 File path for saving the figure. Default is ``None`` (figure is 

1213 not saved). 

1214 

1215 Returns 

1216 ------- 

1217 fig : matplotlib.figure.Figure or None 

1218 The figure, or ``None`` if ``time_data`` does not contain exactly 

1219 two data sets. 

1220 axis : list of matplotlib.axes.Axes or None 

1221 The axes list, or ``None`` as above. 

1222 """ 

1223 if len(self.time_data) == 2: 

1224 # If the CPSD is not defined, compute it automatically 

1225 if all(not isinstance(cpsd, PowerSpectralDensityArray) for cpsd in self.cpsd): 

1226 self.compute_cpsd() 

1227 

1228 # Calculate spacing along the frequency axis (last axis) 

1229 diffs = np.diff(self.cpsd[-1].abscissa, axis=-1) # Shape: (M, N-1) 

1230 

1231 # Create the inner widths (averages of adjacent gaps) 

1232 # Shape: (M, N-2) 

1233 inner_df = (diffs[:, :-1] + diffs[:, 1:]) / 2 

1234 

1235 # Concatenate the boundary conditions 

1236 # First col: same as first diff 

1237 # Last col: same as last diff 

1238 df = np.column_stack([ 

1239 diffs[:, [0]], # Twice half-distance to next 

1240 inner_df, # Centered inner points 

1241 diffs[:, [-1]] # Twice half-distance from previous 

1242 ]) 

1243 

1244 # Calculate Signal Power 

1245 signal_power = np.sum(self.cpsd[-1].ordinate.real*df,axis=1) 

1246 noise_power = np.sum(self.cpsd[0].ordinate.real*df,axis=1) 

1247 

1248 # Calculate Signal to Noise Ratio and Convert to dB 

1249 signal_to_noise_ratios = 10*np.log10(signal_power / noise_power) 

1250 

1251 fig,axis = dynamic_barh(values=signal_to_noise_ratios,labels=self.coordinate) 

1252 

1253 for ax in axis: 

1254 bars = ax.patches 

1255 for bar in bars: 

1256 if bar.get_width() < 10: 

1257 bar.set_facecolor('red') 

1258 ax.axvspan(10, max(signal_to_noise_ratios), alpha=0.6, facecolor='lightgrey',edgecolor='darkgrey',linewidth=1,zorder=0) 

1259 ax.set_xlabel('Signal to Noise Ratio (dB)') 

1260 ax.set_xlim(0,max(signal_to_noise_ratios)) 

1261 fig.suptitle('Signal to Noise Ratio') 

1262 fig.tight_layout() 

1263 

1264 # Save Picture 

1265 if save_path is not None: 

1266 if not os.path.exists(os.path.dirname(save_path)): 

1267 os.makedirs(os.path.dirname(save_path)) 

1268 save_name = save_path 

1269 fig.savefig(save_name,dpi=600 ,bbox_inches='tight') 

1270 

1271 return fig,axis 

1272 

1273 def plot_time_histories(self, save_path: None|str = None): 

1274 """Plot the truncated time history for each channel. 

1275 

1276 For each data set, creates a grid of subplots — one per channel in 

1277 the truncated ``TimeHistoryArray`` — showing the time-domain signal. 

1278 Path simplification is enabled for performance on large records. 

1279 

1280 Parameters 

1281 ---------- 

1282 save_path : str or None, optional 

1283 Base file path for saving figures. The figure index and (when 

1284 multiple data sets exist) data set index are appended to the stem. 

1285 Default is ``None`` (figures are not saved). 

1286 

1287 Returns 

1288 ------- 

1289 figures : list of list of matplotlib.figure.Figure 

1290 ``figures[i]`` is the list of figures for data set *i*. 

1291 axes : list of list of list of matplotlib.axes.Axes 

1292 ``axes[i]`` is the nested list of axis groups for data set *i*. 

1293 """ 

1294 figures = [None]*len(self.time_data) 

1295 axes = [None]*len(self.time_data) 

1296 

1297 for index,time_data_truncated in enumerate(self.time_data_truncated): 

1298 xlim = NDDataArray.get_abscissa_limits(time_data_truncated) 

1299 ylim = NDDataArray.get_ordinate_limits(time_data_truncated,xlim) 

1300 

1301 # Plot Time Data for Response Channels — enable path simplification for large signals 

1302 with plt.rc_context({'path.simplify': True, 'path.simplify_threshold': 1.0, 

1303 'agg.path.chunksize': 10000}): 

1304 all_figs, all_axes = make_multi_figures(len(time_data_truncated),xlabel='Time (s)',ylabel='EU') 

1305 

1306 coordinate_index = 0 

1307 figure_index = 0 

1308 for fig, axes_group in zip(all_figs,all_axes): 

1309 for ax in axes_group: 

1310 coordinate = np.squeeze(time_data_truncated.coordinate)[coordinate_index] 

1311 handles = [] 

1312 labels = [] 

1313 

1314 # Plot Response 

1315 ax.plot(time_data_truncated[coordinate].abscissa,time_data_truncated[coordinate].ordinate,color='deepskyblue',linewidth=1) 

1316 

1317 units = self.units[np.intersect1d(self.coordinate,coordinate,return_indices=True)[1]][0] 

1318 ax.text(0.99, 0.01, 'EU: ' + units,transform=ax.transAxes,ha="right", va="bottom") 

1319 

1320 # Set Plot Properties 

1321 ax.set_title(str(coordinate)) 

1322 ax.set_xlim(min(xlim),max(xlim)) 

1323 ax.set_ylim(-max(np.abs(ylim)),max(np.abs(ylim))) 

1324 

1325 coordinate_index += 1 

1326 

1327 fig.suptitle('Time Histories') 

1328 

1329 # Save Picture 

1330 if save_path is not None: 

1331 if not os.path.exists(os.path.dirname(save_path)): 

1332 os.makedirs(os.path.dirname(save_path)) 

1333 if len(self.time_data) == 1: 

1334 save_name = os.path.splitext(save_path)[0] + ' (Figure ' + str(figure_index + 1) + ')' + os.path.splitext(save_path)[-1] 

1335 else: 

1336 save_name = os.path.splitext(save_path)[0] + ' (Data Set ' + str(index + 1) + ', Figure ' + str(figure_index + 1) + ')' + os.path.splitext(save_path)[-1] 

1337 fig.savefig(save_name,dpi=600 ,bbox_inches='tight') 

1338 

1339 figure_index += 1 

1340 figures[index] = all_figs 

1341 axes[index] = all_axes 

1342 return figures, axes 

1343 

1344 def plot_multiple_coherence(self, save_path: None|str = None): 

1345 """Plot multiple coherence for each response channel within the analysis bandwidth. 

1346 

1347 For each data set, computes the multiple coherence between every 

1348 response channel and all excitation channels (using the same windowing 

1349 parameters as CPSD computation), truncates to the specification 

1350 bandwidth, then produces a grid of subplots with one panel per 

1351 response channel. The y-axis is fixed to ``[0, 1]``. 

1352 

1353 Parameters 

1354 ---------- 

1355 save_path : str or None, optional 

1356 Base file path for saving figures. The figure index and (when 

1357 multiple data sets exist) data set index are appended to the stem. 

1358 Default is ``None`` (figures are not saved). 

1359 

1360 Returns 

1361 ------- 

1362 figures : list of list of matplotlib.figure.Figure 

1363 ``figures[i]`` is the list of figures for data set *i*. 

1364 axes : list of list of list of matplotlib.axes.Axes 

1365 ``axes[i]`` is the nested list of axis groups for data set *i*. 

1366 """ 

1367 

1368 # If the CPSD is not defined, compute it automatically 

1369 if all(not isinstance(cpsd, PowerSpectralDensityArray) for cpsd in self.cpsd): 

1370 self.compute_cpsd() 

1371 

1372 figures = [None]*len(self.time_data) 

1373 axes = [None]*len(self.time_data) 

1374 

1375 for index,(time_data_truncated,cpsd) in enumerate(zip(self.time_data_truncated,self.cpsd)): 

1376 if self.specification_cpsd is not None: 

1377 xlim = NDDataArray.get_abscissa_limits([self.specification_cpsd.get_drive_points(),self.specification_warning_psd,self.specification_abort_psd]) 

1378 else: 

1379 xlim = NDDataArray.get_abscissa_limits(self.cpsd) 

1380 

1381 # Get Time Data 

1382 time_data_response = time_data_truncated[self.response_coordinate[:,np.newaxis]] 

1383 time_data_reference = time_data_truncated[self.excitation_coordinate[:,np.newaxis]] 

1384 

1385 # Compute Coherence 

1386 multiple_coherence = sdpy.data.MultipleCoherenceArray.from_time_data(response_data=time_data_response,samples_per_average=self.samples_per_frame,overlap=self.overlap,window=self.window.lower(),reference_data=time_data_reference) 

1387 

1388 # Truncate Frequency to Bandwith Analyzed 

1389 if not any(lim is None for lim in xlim): 

1390 multiple_coherence = multiple_coherence.extract_elements_by_abscissa(min(xlim),max(xlim)) 

1391 

1392 # Plot Multiple Coherence Results for Response Channels 

1393 all_figs, all_axes = make_multi_figures(len(self.response_coordinate),ylabel='Coherence') 

1394 

1395 coordinate_index = 0 

1396 figure_index = 0 

1397 for fig, axes_group in zip(all_figs,all_axes): 

1398 for ax in axes_group: 

1399 coordinate = self.response_coordinate[coordinate_index] 

1400 

1401 # Plot Multiple Coherence 

1402 abscissa = multiple_coherence[coordinate].abscissa 

1403 ordinate = multiple_coherence[coordinate].ordinate 

1404 ax.plot(abscissa,ordinate,color='deepskyblue',linewidth=1) 

1405 

1406 # Set Plot Properties 

1407 ax.set_title(str(coordinate)) 

1408 if any(lim is None for lim in xlim): 

1409 ax.set_xlim(min(abscissa),max(abscissa)) 

1410 else: 

1411 ax.set_xlim(min(xlim),max(xlim)) 

1412 ax.set_ylim(0,1) 

1413 

1414 coordinate_index += 1 

1415 

1416 fig.suptitle('Multiple Coherence') 

1417 

1418 # Save Picture 

1419 if save_path is not None: 

1420 if not os.path.exists(os.path.dirname(save_path)): 

1421 os.makedirs(os.path.dirname(save_path)) 

1422 if len(self.time_data) == 1: 

1423 save_name = os.path.splitext(save_path)[0] + ' (Figure ' + str(figure_index + 1) + ')' + os.path.splitext(save_path)[-1] 

1424 else: 

1425 save_name = os.path.splitext(save_path)[0] + ' (Data Set ' + str(index + 1) + ', Figure ' + str(figure_index + 1) + ')' + os.path.splitext(save_path)[-1] 

1426 fig.savefig(save_name,dpi=600 ,bbox_inches='tight') 

1427 

1428 figure_index += 1 

1429 figures[index] = all_figs 

1430 axes[index] = all_axes 

1431 return figures, axes 

1432 

1433 def create_all_plots(self, figure_root_path='Figures'): 

1434 """Generate and save all standard diagnostic plots to disk. 

1435 

1436 Computes CPSDs if they have not already been computed, saves a time- 

1437 subset plot, then iterates over all standard plot methods, saving each 

1438 figure to a labelled subdirectory under *figure_root_path*. All 

1439 matplotlib figures are closed after each method to free memory. 

1440 

1441 The plot methods called (and their subfolder names) are: 

1442 

1443 - ``plot_control_vs_spec`` -> ``Control vs Specification PSD Plot`` 

1444 - ``plot_response`` -> ``Response PSD Plot`` 

1445 - ``plot_percent_lines_out`` -> ``Percent Lines Out Plot`` 

1446 - ``plot_rms_level`` -> ``RMS Plot`` 

1447 - ``plot_rms_error`` -> ``RMS Error Plot`` 

1448 - ``plot_kurtosis`` -> ``Kurtosis Plot`` 

1449 - ``plot_time_histories`` -> ``Time Histories Plot`` 

1450 - ``plot_multiple_coherence`` -> ``Multiple Coherence Plot`` 

1451 - ``plot_signal_to_noise`` -> ``Signal to Noise Plot`` 

1452 

1453 Parameters 

1454 ---------- 

1455 figure_root_path : str, optional 

1456 Root directory under which all figure subdirectories are created. 

1457 Default is ``'Figures'``. 

1458 """ 

1459 # If the CPSD is not defined, compute it automatically 

1460 if all(not isinstance(cpsd, PowerSpectralDensityArray) for cpsd in self.cpsd): 

1461 self.compute_cpsd() 

1462 self.plot_cpsd_time_subset(save_path=figure_root_path + os.sep + 'Analyzed Section of Time' + os.sep + 'Analyzed Section of Time Plot') 

1463 

1464 # Specify Folder Names for Each Set of Plots 

1465 # Keys are Method Names 

1466 # Values are a sub-dictionary with keys 'plot_folder' and 'plot_name' 

1467 path_and_plot_names = { 

1468 'plot_control_vs_spec':{'plot_folder':'Control vs Specification PSD Plot','plot_name':'Control vs Specification PSD Plot'}, 

1469 'plot_response':{'plot_folder':'Response PSD Plot','plot_name':'Response PSD Plot'}, 

1470 'plot_percent_lines_out':{'plot_folder':'Percent Lines Out Plot','plot_name':'Percent Lines Out Plot'}, 

1471 'plot_rms_level':{'plot_folder':'RMS Plot','plot_name':'RMS Plot'}, 

1472 'plot_rms_error':{'plot_folder':'RMS Error Plot','plot_name':'RMS Error Plot'}, 

1473 'plot_kurtosis':{'plot_folder':'Kurtosis Plot','plot_name':'Kurtosis Plot'}, 

1474 'plot_time_histories':{'plot_folder':'Time Histories Plot','plot_name':'Time Histories Plot'}, 

1475 'plot_multiple_coherence':{'plot_folder':'Multiple Coherence Plot','plot_name':'Multiple Coherence Plot'}, 

1476 'plot_signal_to_noise':{'plot_folder':'Signal to Noise Plot','plot_name':'Signal to Noise Plot'}, 

1477 } 

1478 

1479 # Create Plots for Each Plot Method 

1480 for method_name in path_and_plot_names: 

1481 method = getattr(self,method_name) 

1482 method(save_path = figure_root_path + os.sep + path_and_plot_names[method_name]['plot_folder'] + os.sep + path_and_plot_names[method_name]['plot_name']) 

1483 plt.close('all') 

1484 

1485 def convert_to_octave(self, oct_order = 6): 

1486 """Convert all stored PSDs to octave-band averages. 

1487 

1488 Restricts the analysis to the specification bandwidth, computes nominal 

1489 octave-band centres for the given *oct_order*, then replaces the stored 

1490 CPSDs, specification CPSD, warning PSD, and abort PSD with their 

1491 octave-band equivalents using ``bandwidth_average``. Sets 

1492 ``self.oct_order`` so that subsequent RMS computations use octave 

1493 integration. 

1494 

1495 Parameters 

1496 ---------- 

1497 oct_order : int, optional 

1498 Octave-band order (e.g. 3 for third-octave, 6 for sixth-octave). 

1499 Default is 6. 

1500 """ 

1501 self.oct_order = oct_order 

1502 

1503 # Compute CPSDs if they don't already exsist 

1504 if all(not isinstance(cpsd, PowerSpectralDensityArray) for cpsd in self.cpsd): 

1505 self.compute_cpsd() 

1506 

1507 # Get Analysis Bandwidth 

1508 xlim = NDDataArray.get_abscissa_limits([self.specification_cpsd.get_drive_points(),self.specification_warning_psd,self.specification_abort_psd]) 

1509 

1510 # Get Frequency Spacing 

1511 nominal_band_centers,band_lb,band_ub,band_centers = sdpy.cpsd.nth_octave_freqs(freq=xlim,oct_order=oct_order) 

1512 

1513 # Convert to Octave Space 

1514 for index,cpsd in enumerate(self.cpsd): 

1515 self.cpsd[index] = cpsd.extract_elements_by_abscissa(min(xlim),max(xlim)).bandwidth_average(band_lb,band_ub) 

1516 

1517 self.specification_cpsd = self.specification_cpsd.extract_elements_by_abscissa(min(xlim),max(xlim)).bandwidth_average(band_lb,band_ub) 

1518 self.specification_warning_psd = self.specification_warning_psd.extract_elements_by_abscissa(min(xlim),max(xlim)).bandwidth_average(band_lb,band_ub) 

1519 self.specification_abort_psd = self.specification_abort_psd.extract_elements_by_abscissa(min(xlim),max(xlim)).bandwidth_average(band_lb,band_ub) 

1520 

1521 @classmethod 

1522 def load_rattlesnake_streaming_data(cls, file: os.PathLike | RattlesnakeData, 

1523 environment_name: str = None, 

1524 # time_array_index: int = 0, 

1525 geometry: os.PathLike | Geometry = None): 

1526 """Construct a ``RandomVibTest`` from a Rattlesnake streaming nc4 file. 

1527 

1528 Reads (or reuses) a ``RattlesnakeData`` object, selects the 

1529 requested environment, and populates a new ``RandomVibTest`` with all 

1530 time data, coordinate arrays, specification PSDs, and spectral 

1531 processing parameters drawn from the file. 

1532 

1533 Parameters 

1534 ---------- 

1535 file : os.PathLike or RattlesnakeData 

1536 Path to a Rattlesnake nc4 file, or an already-parsed 

1537 ``RattlesnakeData`` object. 

1538 environment_name : str, optional 

1539 Name of the environment group to load. When ``None`` (default) 

1540 the first environment in the file is used. A warning is emitted 

1541 when the file contains more than one environment and none is 

1542 specified. 

1543 geometry : os.PathLike or Geometry, optional 

1544 Path to a geometry file or a ``Geometry`` object for 3-D 

1545 visualisation. Default is ``None``. 

1546 

1547 Returns 

1548 ------- 

1549 RandomVibTest 

1550 Fully initialised ``RandomVibTest`` instance ready for CPSD 

1551 computation and plotting. 

1552 

1553 Warns 

1554 ----- 

1555 UserWarning 

1556 If the file contains multiple environments and *environment_name* 

1557 is not specified. 

1558 """ 

1559 if not isinstance(file, RattlesnakeData): 

1560 data = RattlesnakeData.read_rattlesnake_nc4(file) 

1561 else: 

1562 data = file 

1563 

1564 if environment_name is None: 

1565 environment_name = next(iter(data.environments)) 

1566 if len(data.environments) > 1: 

1567 warnings.warn('There are multiple environments in the Rattlesnake data and none were specified. Using the first environmnet named: ' + environment_name + ' .') 

1568 

1569 return cls( 

1570 source_data = data.environments[environment_name], 

1571 geometry = geometry, 

1572 coordinate = data.get_coordinate(env_name=environment_name), 

1573 control_coordinate = data.environments[environment_name].control_coordinate, 

1574 response_coordinate = data.response_coordinate, 

1575 excitation_coordinate = data.excitation_coordinate, 

1576 time_data = data.get_time_data(env_name=environment_name), 

1577 start_time = [None]*len(data.get_time_data(env_name=environment_name)), 

1578 specification_cpsd = data.environments[environment_name].specification_cpsd, 

1579 specification_warning_psd = data.environments[environment_name].specification_warning_psd, 

1580 specification_abort_psd = data.environments[environment_name].specification_abort_psd, 

1581 averages = data.environments[environment_name].sysid_averages, 

1582 overlap = data.environments[environment_name].cpsd_overlap, 

1583 fft_lines = data.environments[environment_name].fft_lines, 

1584 window = data.environments[environment_name].cpsd_window, 

1585 samples_per_frame = data.environments[environment_name].samples_per_frame, 

1586 cpsd = [], 

1587 units = data.units(env_name=environment_name), 

1588 oct_order = None, 

1589 )