Coverage for src/sdynpy/signal_processing/sdynpy_srs.py: 8%

880 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-18 21:32 +0000

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

2""" 

3Created on Fri Nov 3 09:23:59 2023 

4 

5@author: dprohe 

6""" 

7 

8import numpy as np 

9from scipy.signal import lfilter 

10import matplotlib.pyplot as plt 

11from scipy.signal import oaconvolve, windows 

12from scipy.optimize import minimize, NonlinearConstraint, nnls 

13from scipy.interpolate import interp1d 

14from scipy.integrate import cumulative_trapezoid 

15from dataclasses import dataclass 

16from typing import Optional, List, Dict, Any 

17 

18 

19def srs(signal, dt, frequencies=None, damping=0.05, spectrum_type=9, 

20 b_filter_weights=None, a_filter_weights=None): 

21 """ 

22 Computes shock response spectrum of a signal 

23 

24 Computes the shock response spectrum using a ramp invariant digital filter 

25 simulation of the single degree-of-freedom system. 

26 

27 Parameters 

28 ---------- 

29 signal : np.ndarray 

30 A shape (..., num_samples) ndarray containing the sampled signals to 

31 compute SRSs from. 

32 dt : float 

33 The time between samples (1/sample rate) 

34 frequencies : np.ndarray 

35 An iterable of frequency lines at which the shock response spectrum 

36 will be calculated. If not specified, it will be computed from the 

37 sample rate/10000 to the sample rate/4 over 50 lines. 

38 damping : float 

39 Fraction of critical damping to use in the SRS calculation (e.g. you 

40 should specify 0.03 to represent 3%, not 3). The default is 0.03. 

41 spectrum_type : int 

42 The type of spectrum desired: 

43 If `spectrum_type` > 0 (pos) then the SRS will be a base 

44 acceleration-absolute acceleration model 

45 If `spectrum_type` < 0 (neg) then the SRS will be a base acceleration-relative 

46 displacement model (expressed in equivalent static acceleration units). 

47 If abs(`spectrum_type`) is: 

48 1--positive primary, 2--negative primary, 3--absolute maximum primary 

49 4--positive residual, 5--negative residual, 6--absolute maximum residual 

50 7--largest of 1&4, maximum positive, 8--largest of 2&5, maximum negative 

51 9 -- maximax, the largest absolute value of 1-8 

52 10 -- returns a matrix s(9,length(fn)) with all the types 1-9. 

53 b_filter_weights : np.ndarray, optional 

54 Optional filter weights with shape (frequencies.size,3). 

55 The default is to automatically compute filter weights using 

56 `sdof_ramp_invariant_filter_weights`. 

57 a_filter_weights : np.ndarray, optional 

58 Optional filter weights with shape (frequencies.size,3). 

59 The default is to automatically compute filter weights using 

60 `sdof_ramp_invariant_filter_weights`. 

61 

62 Returns 

63 ------- 

64 srs : np.ndarray 

65 The shock response spectrum at the frequencies specified. If 

66 `spectrum_type` == 10 or -10, then this will have shape 

67 (... x 9 x len(frequencies)) where each row is a different type of SRS 

68 frequencies : np.ndarray 

69 The frequencies at which the SRSs were computed. 

70 """ 

71 # Compute default parameters 

72 sample_rate = 1/dt 

73 if frequencies is None: 

74 frequencies = np.logspace(np.log10(sample_rate/1e4), 

75 np.log10(sample_rate/4), 

76 50) 

77 else: 

78 frequencies = np.array(frequencies).flatten() 

79 if frequencies.size == 0: 

80 raise ValueError('Frequencies must have nonzero size') 

81 signal = np.array(signal) 

82 num_samples = signal.shape[-1] 

83 

84 # Compute bad parameters 

85 abs_type = abs(spectrum_type) 

86 if abs_type > 10: 

87 raise ValueError("`spectrum_type` must be in the range [-10,10]") 

88 if abs_type == 0: 

89 raise ValueError('`spectrum_type` must not be 0') 

90 if sample_rate <= 0: 

91 raise ValueError('`dt` must be positive') 

92 if frequencies.min() < 0: 

93 raise ValueError('`frequencies` must all be positive') 

94 if damping < 0: 

95 raise ValueError('`damping` must be positive') 

96 if damping == 0: 

97 print('Warning: Damping is Zero in SRS calculation!') 

98 

99 # Get the filter coefficients if not specified 

100 if b_filter_weights is None or a_filter_weights is None: 

101 b_filter_weights, a_filter_weights = sdof_ramp_invariant_filter_weights( 

102 frequencies, sample_rate, damping, spectrum_type) 

103 else: 

104 b_filter_weights = np.atleast_2d(np.array(b_filter_weights)) 

105 a_filter_weights = np.atleast_2d(np.array(a_filter_weights)) 

106 

107 if b_filter_weights.shape != (frequencies.size, 3): 

108 raise ValueError('`b_filter_weights` and `a_filter_weights` must have shape (frequencies.size,3)') 

109 

110 srs_output = np.zeros(signal.shape[:-1] 

111 + ((9,) if np.abs(spectrum_type) == 10 else ()) 

112 + (frequencies.size,)) 

113 for i_freq, (frequency, b, a) in enumerate(zip( 

114 frequencies, b_filter_weights, a_filter_weights)): 

115 # Append enough zeros to get 1/100 of the residual response 

116 zeros_to_append = int(np.max([2, np.ceil(0.01*sample_rate/frequency)])) 

117 signals_extended = np.concatenate(( 

118 signal, np.zeros(signal.shape[:-1] + (zeros_to_append,))), axis=-1) 

119 

120 # Filter the signal 

121 response = sdof_filter(b, a, signals_extended) 

122 

123 # Now compute the SRS parameters 

124 # Primary response 

125 primary_maximum = np.array(np.max(response[..., :num_samples], axis=-1)) 

126 primary_maximum[primary_maximum < 0] = 0 

127 primary_minimum = np.array(np.abs(np.min(response[..., :num_samples], axis=-1))) 

128 primary_minimum[primary_minimum < 0] = 0 

129 primary_abs = np.array(np.max([primary_maximum, primary_minimum], axis=0)) 

130 

131 # Now compute the residual response. we need time steps and 

132 # responses at two points 

133 residual_times = [0, (zeros_to_append-1)/sample_rate] 

134 residual_responses = np.concatenate([response[..., num_samples, np.newaxis], 

135 response[..., num_samples+zeros_to_append-1, np.newaxis]], 

136 axis=-1) 

137 

138 # Compute the peak response after the structure has stopped responding 

139 peak_residual_responses = sdof_free_decay_peak_response( 

140 residual_responses, residual_times, frequency, damping) 

141 

142 # Pull out the residual peaks 

143 residual_maximum = np.array(np.max(peak_residual_responses, axis=-1)) 

144 residual_maximum[residual_maximum < 0] = 0 

145 residual_minimum = np.array(np.abs(np.min(peak_residual_responses, axis=-1))) 

146 residual_minimum[residual_minimum < 0] = 0 

147 residual_abs = np.array(np.max([residual_maximum, residual_minimum], axis=0)) 

148 

149 all_srs_this_frequency_line = np.array([ 

150 primary_maximum, primary_minimum, primary_abs, 

151 residual_maximum, residual_minimum, residual_abs, 

152 np.max([primary_maximum, residual_maximum], axis=0), 

153 np.max([primary_minimum, residual_minimum], axis=0), 

154 np.max([primary_abs, residual_abs], axis=0)]) 

155 

156 if abs_type < 10: 

157 srs_output[..., i_freq] = all_srs_this_frequency_line[abs_type-1] 

158 else: 

159 srs_output[..., i_freq] = np.moveaxis(all_srs_this_frequency_line, 0, -1) 

160 return srs_output, frequencies 

161 

162 

163def sdof_ramp_invariant_filter_weights(frequencies, sample_rate, damping, 

164 spectrum_type): 

165 """ 

166 Computes filter weights for SDOF resonators using a ramp-invariant filter. 

167 

168 The weights are used in conjunction with the function 

169 `sdof_filter` and `srs` to calculate the shock response 

170 spectrum of an acceleration time history using a ramp 

171 invarient simulation. 

172 

173 Parameters 

174 ---------- 

175 frequencies : np.ndarray 

176 The frequencies at which filters should be computed 

177 sample_rate : float 

178 The sample rate of the measurement 

179 damping : float 

180 The fraction of critical damping for the system (e.g. 0.03, not 3 for 3%) 

181 spectrum_type : int 

182 See `spectrum_type` argument for compute_srs 

183 

184 Returns 

185 ------- 

186 b : np.ndarray 

187 Filter coefficients with shape (frequencies.shape,3) 

188 a : np.ndarray 

189 Filter coefficients with shape (frequencies.shape,3) 

190 """ 

191 # Make sure it's a numpy array 

192 frequencies = np.array(frequencies) 

193 # Preallocate arrays 

194 a = np.ones(frequencies.shape+(3,)) 

195 b = np.ones(frequencies.shape+(3,)) 

196 

197 normalized_frequencies = 2*np.pi*frequencies/sample_rate 

198 

199 small_freq_indices = normalized_frequencies < 0.001 

200 

201 # Small frequencies 

202 small_freqs = normalized_frequencies[small_freq_indices] 

203 

204 # 2*z*w + w*w*(1-2*z*z) where z is damping and w is normalized frequencies 

205 a[small_freq_indices, 1] = ( 

206 2*damping*small_freqs 

207 + small_freqs**2*(1-2*damping**2) 

208 ) 

209 # -2*z*w + 2*z*z*w*w where z is damping and w is normalized frequencies 

210 a[small_freq_indices, 2] = ( 

211 -2*damping*small_freqs 

212 + 2*damping**2*small_freqs**2 

213 ) 

214 

215 if spectrum_type > 0: 

216 # Absolute Acceleration Model 

217 # z*w + (w*w)*( (1/6) - 2*z*z/3 ); 

218 b[small_freq_indices, 0] = ( 

219 damping*small_freqs 

220 + small_freqs**2 

221 * (1/6 - 2*damping**2/3) 

222 ) 

223 # 2*w*w*(1-z*z)/3 

224 b[small_freq_indices, 1] = ( 

225 2*small_freqs**2*(1-damping**2)/3 

226 ) 

227 # -z*w + w*w*( (1/6) - 4*z*z/3 ); 

228 b[small_freq_indices, 2] = ( 

229 - damping*small_freqs 

230 + small_freqs**2 

231 * (1/6 - 4*damping**2/3) 

232 ) 

233 else: 

234 # Relative Displacement Model 

235 # -w*w/6; 

236 b[small_freq_indices, 0] = -small_freqs**2/6 

237 # -2*w*w/3; 

238 b[small_freq_indices, 1] = -2*small_freqs**2/3 

239 # -w*w/6; 

240 b[small_freq_indices, 2] = -small_freqs**2/6 

241 

242 # Large frequencies 

243 large_freqs = normalized_frequencies[~small_freq_indices] 

244 

245 # Define some helper variables 

246 sq = np.sqrt(1-damping**2) 

247 e = np.exp(-damping*large_freqs) 

248 wd = large_freqs*sq 

249 sp = e*np.sin(wd) 

250 fact = (2*damping**2 - 1)*sp/sq 

251 c = e*np.cos(wd) 

252 

253 a[~small_freq_indices, 1] = 2*(1-c) 

254 a[~small_freq_indices, 2] = -1 + e**2 

255 

256 if spectrum_type > 0: 

257 # Absolute Acceleration Model 

258 spwd = sp/wd 

259 b[~small_freq_indices, 0] = 1-spwd 

260 b[~small_freq_indices, 1] = 2*(spwd - c) 

261 b[~small_freq_indices, 2] = e**2 - spwd 

262 else: 

263 # Relative Displacement Model 

264 b[~small_freq_indices, 0] = -(2*damping*(c-1) + fact + large_freqs)/large_freqs 

265 b[~small_freq_indices, 1] = -(-2*c*large_freqs + 2*damping*(1-e**2) - 2*fact)/large_freqs 

266 b[~small_freq_indices, 2] = -(e**2*(large_freqs+2*damping) - 2*damping*c + fact)/large_freqs 

267 

268 return b, a 

269 

270 

271def sdof_filter(b, a, signal, zi=None): 

272 """ 

273 Applies a filter to simulate a single degree of freedom system 

274 

275 Parameters 

276 ---------- 

277 b : np.ndarray 

278 Size 3 array representing filter coefficients used by scipy.signal.lfilter 

279 a : np.array 

280 Size 3 array representing filter coefficients used by scipy.signal.lfilter 

281 signal : np.ndarray 

282 The signals that are to be filtered 

283 zi : np.ndarray, optional 

284 Optional initial state for the filters, having length 

285 `max(len(a), len(b)) - 1`. If not specified, zero initial conditions 

286 are assumed. 

287 

288 Returns 

289 ------- 

290 filtered_signal : np.ndarray 

291 The filtered signal 

292 

293 """ 

294 aa = np.array([a[0], a[1]-2, a[2] + 1]) 

295 return lfilter(b, aa, signal, axis=-1, zi=zi) 

296 

297 

298def sdof_free_decay_peak_response(responses, times_at_responses, frequency, damping): 

299 """ 

300 Calculates peak response of a freely-decaying sdof system. 

301 

302 The residual response is the peak response of the sdof system 

303 as it decays after the input has ended, i.e., the input is zero. 

304 The first two peaks in the decaying response will be the largest. 

305 One peak will be negative and one positive. We don't know before 

306 the calculation if the first and largest peak in amplitude will be 

307 positive or negative. 

308 

309 Parameters 

310 ---------- 

311 responses : np.array 

312 A (...,2) shape array giving the response at two time steps 

313 times_at_responses : np.array 

314 A length-2 array giving the time values that the responses occur at 

315 frequency : float 

316 The frequency at which the computation is performed 

317 damping : float 

318 The fraction of critical damping for the system (e.g. 0.03, not 3 for 3%) 

319 

320 Returns 

321 ------- 

322 response_peaks : np.ndarray 

323 A shape (...,2) array giving the amplitude at the two peaks after the 

324 response has decayed 

325 

326 Notes 

327 ----- 

328 The response has the form 

329 a(t) = exp(-zeta wn t)[z[0]sin(wd t) + z[1]cos(wd t)]. 

330 If I know a(t) at two values of time, t, I can calculate the constants 

331 z[0] and z[1]. The general form of the response can then be solved for 

332 the maximum by finding the time of the first maximum by setting the 

333 derivative to zero. Then substituting the time of the maximum response 

334 back into the general equation to find the maximum response. 

335 The second peak will occur half a cycle later. 

336 

337 """ 

338 responses = np.array(responses) 

339 times_at_responses = np.array(times_at_responses) 

340 # Set up some initial helper variables 

341 fd = frequency*np.sqrt(1-damping*damping) 

342 wd = 2*np.pi*fd 

343 wn = 2*np.pi*frequency 

344 wdt = wd*times_at_responses 

345 e = np.exp(-wn*damping*times_at_responses) 

346 sd = np.sin(wdt) 

347 cd = np.cos(wdt) 

348 c = e[:, np.newaxis]*np.array((sd, cd)).T 

349 z = np.linalg.solve(c, responses[..., np.newaxis])[..., 0] 

350 

351 # the response has the form 

352 # a(t) = exp(-zeta wn t)[z[0]sin(wd t) + z[1]cos(wd t)] 

353 # the first maximum response will occur at the time tmax 

354 tmax = np.array((1/wd) * np.arctan((wd*z[..., 0] - damping*wn*z[..., 1])/(wd*z[..., 1] - damping*wn*z[..., 0]))) 

355 tmax[tmax < 0] = tmax[tmax < 0] + np.pi/wd 

356 tmax[tmax > np.pi/wd] = tmax[tmax > np.pi/wd] - np.pi/wd 

357 

358 # We can now plug it into the equation to find the maximum 

359 response_peaks = [] 

360 response_peaks.append(np.exp(-damping*wn*tmax)*(z[..., 0]*np.sin(wd*tmax) + z[..., 1]*np.cos(wd*tmax))) 

361 tmin = tmax + np.pi/wd 

362 response_peaks.append(np.exp(-damping*wn*tmin)*(z[..., 0]*np.sin(wd*tmin) + z[..., 1]*np.cos(wd*tmin))) 

363 return np.moveaxis(response_peaks, 0, -1) 

364 

365 

366def octspace(low, high, points_per_octave): 

367 """ 

368 Constructs octave spacing between low and high values 

369 

370 Parameters 

371 ---------- 

372 low : float 

373 Starting value for the spacing 

374 high : float 

375 Upper value for the spacing 

376 points_per_octave : int 

377 Number of points per octave 

378 

379 Returns 

380 ------- 

381 octave_points : np.ndarray 

382 Octave-spaced points 

383 """ 

384 num_octaves = np.log2(high/low) 

385 num_steps = np.ceil(num_octaves*points_per_octave) 

386 point_indices = np.arange(num_steps+1) 

387 log_points = np.log2(low) + num_octaves/num_steps*point_indices 

388 points = 2**log_points 

389 return points 

390 

391 

392def sum_decayed_sines(sample_rate, block_size, 

393 sine_frequencies=None, sine_tone_range=None, sine_tone_per_octave=None, 

394 sine_amplitudes=None, sine_decays=None, sine_delays=None, 

395 required_srs=None, srs_breakpoints=None, 

396 srs_damping=0.05, srs_type=9, 

397 compensation_frequency=None, compensation_decay=0.95, 

398 # Paramters for the iteration 

399 number_of_iterations=3, convergence=0.8, 

400 error_tolerance=0.05, 

401 tau=None, num_time_constants=None, decay_resolution=None, 

402 scale_factor=1.02, 

403 acceleration_factor=1.0, 

404 plot_results=False, srs_frequencies=None, 

405 ignore_compensation_pulse = False, 

406 verbose=False 

407 ): 

408 """ 

409 Generate a Sum of Decayed Sines signal given an SRS. 

410 

411 Note that there are many approaches to do this, with many optional arguments 

412 so please read the documentation carefully to understand which arguments 

413 must be passed to the function. 

414 

415 Parameters 

416 ---------- 

417 sample_rate : float 

418 The sample rate of the generated signal. 

419 block_size : int 

420 The number of samples in the generated signal. 

421 sine_frequencies : np.ndarray, optional 

422 The frequencies of the sine tones. If this argument is not specified, 

423 then the `sine_tone_range` argument must be specified. 

424 sine_tone_range : np.ndarray, optional 

425 A length-2 array containing the minimum and maximum sine tone to 

426 generate. If this is argument is not specified, then the 

427 `sine_frequencies` argument must be specified instead. 

428 sine_tone_per_octave : int, optional 

429 The number of sine tones per octave. If not specified along with 

430 `sine_tone_range`, then a default value of 4 will be used if the 

431 `srs_damping` is >= 0.05. Otherwise, the formula of 

432 `sine_tone_per_octave = 9 - srs_damping*100` will be used. 

433 sine_amplitudes : np.ndarray, optional 

434 The initial amplitude of the sine tones used in the optimization. If 

435 not specified, they will be set to the value of the SRS at each frequency 

436 divided by the quality factor of the SRS. 

437 sine_decays : np.ndarray, optional 

438 An array of decay value time constants (often represented by variable 

439 tau). Tau is the time for the amplitude of motion to decay 63% defined 

440 by the equation `1/(2*np.pi*freq*zeta)` where `freq` is the frequency 

441 of the sine tone and `zeta` is the fraction of critical damping. 

442 If not specified, then either the `tau` or `num_time_constants` 

443 arguments must be specified instead. 

444 sine_delays : np.ndarray, optional 

445 An array of delay values for the sine components. If not specified, 

446 all tones will have zero delay. 

447 required_srs : np.ndarray, optional 

448 The SRS to match defined at each of the `sine_frequencies`. If this 

449 argument is not passed, then the `srs_breakpoints` argument must be 

450 passed instead. The default is None. 

451 srs_breakpoints : np.ndarray, optional 

452 A numpy array with shape `(n,2)` where the first column is the 

453 frequencies at which each breakpoint occurs, and the second column is 

454 the value of the SRS at that breakpoint. SRS values at the 

455 `sine_frequencies` will be interpolated in a log-log sense from this 

456 breakpoint array. If this argument is not specified, then the 

457 `required_srs` must be passed instead. 

458 srs_damping : float, optional 

459 Fraction of critical damping to use in the SRS calculation (e.g. you 

460 should specify 0.03 to represent 3%, not 3). The default is 0.03. 

461 srs_type : int 

462 The type of spectrum desired: 

463 If `srs_type` > 0 (pos) then the SRS will be a base 

464 acceleration-absolute acceleration model 

465 If `srs_type` < 0 (neg) then the SRS will be a base acceleration-relative 

466 displacement model (expressed in equivalent static acceleration units). 

467 If abs(`srs_type`) is: 

468 1--positive primary, 2--negative primary, 3--absolute maximum primary 

469 4--positive residual, 5--negative residual, 6--absolute maximum residual 

470 7--largest of 1&4, maximum positive, 8--largest of 2&5, maximum negative 

471 9 -- maximax, the largest absolute value of 1-8 

472 10 -- returns a matrix s(9,length(fn)) with all the types 1-9. 

473 compensation_frequency : float 

474 The frequency of the compensation pulse. If not specified, it will be 

475 set to 1/3 of the lowest sine tone 

476 compensation_decay : float 

477 The decay value for the compensation pulse. If not specified, it will 

478 be set to 0.95. 

479 number_of_iterations : int, optional 

480 The number of iterations to perform. At least two iterations should be 

481 performed. 3 iterations is preferred, and will be used if this argument 

482 is not specified. 

483 convergence : float, optional 

484 The fraction of the error corrected each iteration. The default is 0.8. 

485 error_tolerance : float, optional 

486 Allowable relative error in the SRS. The default is 0.05. 

487 tau : float, optional 

488 If a floating point number is passed, then this will be used for the 

489 `sine_decay` values. Alternatively, a dictionary can be passed with 

490 the keys containing a length-2 tuple specifying the minimum and maximum 

491 frequency range, and the value specifying the value of `tau` within that 

492 frequency range. If this latter approach is used, all `sine_frequencies` 

493 must be contained within a frequency range. If this argument is not 

494 specified, then either `sine_decays` or `num_time_constants` must be 

495 specified instead. 

496 num_time_constants : int, optional 

497 If an integer is passed, then this will be used to set the `sine_decay` 

498 values by ensuring the specified number of time constants occur in the 

499 `block_size`. Alternatively, a dictionary can be passed with the keys 

500 containing a length-2 tuple specifying the minimum and maximum 

501 frequency range, and the value specifying the value of 

502 `num_time_constants` over that frequency range. If this latter approach 

503 is used, all `sine_frequencies` must be contained within a frequency 

504 range. If this argument is not specified, then either `sine_decays` or 

505 `tau` must be specified instead. 

506 decay_resolution : float, optional 

507 A scalar identifying the resolution of the fractional decay rate 

508 (often known by the variable `zeta`). The decay parameters will be 

509 rounded to this value. The default is to not round. 

510 scale_factor : float, optional 

511 A scaling applied to the sine tone amplitudes so the achieved SRS better 

512 fits the specified SRS, rather than just touching it. The default is 1.02. 

513 acceleration_factor : float, optional 

514 Optional scale factor to convert acceleration into velocity and 

515 displacement. For example, if sine amplitudes are in G and displacement 

516 is desired in inches, the acceleration factor should be set to 386.089. 

517 If sine amplitudes are in G and displacement is desired in meters, the 

518 acceleration factor should be set to 9.80665. The default is 1, which 

519 assumes consistent units (e.g. acceleration in m/s^2, velocity in m/s, 

520 displacement in m). 

521 plot_results : bool, optional 

522 If True, a figure will be plotted showing the acceleration, velocity, 

523 and displacement signals, as well as the desired and achieved SRS. 

524 srs_frequencies : np.ndarray, optional 

525 If specified, these frequencies will be used to compute the SRS that 

526 will be plotted when the `plot_results` value is `True`. 

527 ignore_compensation_pulse : bool, optional 

528 If True, the compensation pulse will be ignored. 

529 verbose : True, optional 

530 If True, additional diagnostics will be printed to the console. 

531 

532 Returns 

533 ------- 

534 acceleration_signal : ndarray 

535 The acceleration signal that satisfies the SRS. 

536 velocity_signal : ndarray 

537 The velocity of the acceleration signal. 

538 displacement_signal : ndarray 

539 The displacement of the acceleration signal. 

540 sine_frequencies : ndarray 

541 An array of frequencies for each sine tone, including the compensation 

542 pulse 

543 sine_amplitudes : ndarray 

544 An array of amplitudes for each sine tone, including the compensation 

545 pulse 

546 sine_decays : ndarray 

547 An array of decay values for each sine tone, including the compensation 

548 pulse 

549 sine_delays : ndarray 

550 An array of delay values for each sine tone, including the compensation 

551 pulse 

552 fig : matplotlib.Figure 

553 A reference to the plotted figure. Only returned if `plot_results` is 

554 `True`. 

555 ax : matplotlib.Axes 

556 A reference to the plotted axes. Only returned if `plot_results` is 

557 `True`. 

558 

559 """ 

560 # Handle the sine tone frequencies 

561 if sine_frequencies is None and sine_tone_range is None: 

562 raise ValueError('Either `sine_frequencies` or `sine_tone_range` must be specified') 

563 if sine_frequencies is not None and sine_tone_range is not None: 

564 raise ValueError('`sine_frequencies` can not be specified simultaneously with `sine_tone_range`') 

565 if sine_frequencies is None: 

566 # Create sine tones 

567 if sine_tone_per_octave is None: 

568 sine_tone_per_octave = int(np.floor(9-srs_damping*100)) 

569 sine_frequencies = octspace(sine_tone_range[0], sine_tone_range[1], 

570 sine_tone_per_octave) 

571 # Now set up the SRS 

572 if required_srs is None and srs_breakpoints is None: 

573 raise ValueError('Either `required_srs` or `srs_breakpoints` must be specified') 

574 if required_srs is not None and srs_breakpoints is not None: 

575 raise ValueError('`required_srs` can not be specified simultaneously with `srs_breakpoints`') 

576 if required_srs is None: 

577 required_srs = loginterp(sine_frequencies, 

578 srs_breakpoints[:, 0], 

579 srs_breakpoints[:, 1]) 

580 if sine_amplitudes is None: 

581 srs_amplitudes = required_srs.copy() 

582 srs_amplitudes[np.arange(srs_amplitudes.size) % 2 == 0] *= -1 

583 quality = 1/(2*srs_damping) 

584 srs_amplitudes /= quality 

585 sine_amplitudes = srs_amplitudes 

586 

587 if sine_delays is None: 

588 sine_delays = np.zeros(sine_frequencies.size) 

589 

590 if compensation_frequency is None: 

591 compensation_frequency = np.min(sine_frequencies)/3 

592 

593 # Set up decay terms 

594 decay_terms_specified = 0 

595 if sine_decays is not None: 

596 decay_terms_specified += 1 

597 if tau is not None: 

598 decay_terms_specified += 1 

599 if num_time_constants is not None: 

600 decay_terms_specified += 1 

601 if decay_terms_specified == 0: 

602 raise ValueError('One of `sine_decays`, `tau`, or `num_time_constants` must be specified') 

603 if decay_terms_specified > 1: 

604 raise ValueError('Only one of `sine_decays`, `tau`, or `num_time_constants` can be specified') 

605 

606 # Now check and see which is defined 

607 if num_time_constants is not None: 

608 period = block_size / sample_rate 

609 if isinstance(num_time_constants, dict): 

610 tau = {} 

611 for freq_range, num_time_constant in num_time_constants.items(): 

612 tau[freq_range] = period / num_time_constant 

613 else: 

614 tau = period/num_time_constants 

615 if tau is not None: 

616 sine_decays = [] 

617 for freq in sine_frequencies: 

618 if isinstance(tau, dict): 

619 this_decay = None 

620 for freq_range, this_tau in tau.items(): 

621 if freq_range[0] <= freq <= freq_range[1]: 

622 this_decay = 1/(2*np.pi*freq*this_tau) 

623 break 

624 if this_decay is None: 

625 raise ValueError('No frequency range matching frequency {:} was found in the specified decay parameters.'.format(freq)) 

626 sine_decays.append(this_decay) 

627 else: 

628 sine_decays.append(1/(2*np.pi*freq*tau)) 

629 sine_decays = np.array(sine_decays) 

630 # Otherwise we just keep the specified sine_decays 

631 

632 # Now handle the minimum resolution on the decay 

633 if decay_resolution is not None: 

634 sine_decays = decay_resolution*np.round(sine_decays/decay_resolution) 

635 

636 if compensation_frequency is None: 

637 compensation_frequency = np.min(sine_frequencies)/3 

638 

639 # Now we can actually run the generation process 

640 (acceleration_signal, used_frequencies, used_amplitudes, used_decays, 

641 used_delays, used_comp_frequency, used_comp_amplitude, used_comp_decay, 

642 used_comp_delay) = _sum_decayed_sines( 

643 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, 

644 scale_factor*required_srs, compensation_frequency, compensation_decay, 

645 sample_rate, block_size, srs_damping, srs_type, number_of_iterations, 

646 convergence, error_tolerance, ignore_compensation_pulse, verbose) 

647 

648 all_frequencies = np.concatenate((used_frequencies, 

649 [used_comp_frequency])) 

650 all_amplitudes = np.concatenate((used_amplitudes, 

651 [used_comp_amplitude])) 

652 all_decays = np.concatenate((used_decays, 

653 [used_comp_decay])) 

654 all_delays = np.concatenate((used_delays, 

655 [used_comp_delay])) 

656 # Now compute displacement and velocity 

657 velocity_signal, displacement_signal = sum_decayed_sines_displacement_velocity( 

658 all_frequencies, all_amplitudes, all_decays, all_delays, sample_rate, 

659 block_size, acceleration_factor) 

660 

661 return_vals = (acceleration_signal, velocity_signal, displacement_signal, 

662 all_frequencies, all_amplitudes, all_decays, all_delays) 

663 

664 # Plot the results 

665 if plot_results: 

666 fig, ax = plt.subplots(2, 2, figsize=(8, 6)) 

667 times = np.arange(block_size)/sample_rate 

668 ax[0, 0].plot(times, acceleration_signal) 

669 ax[0, 0].set_ylabel('Acceleration') 

670 ax[0, 0].set_xlabel('Time (s)') 

671 ax[0, 1].plot(times, velocity_signal) 

672 ax[0, 1].set_ylabel('Velocity') 

673 ax[0, 1].set_xlabel('Time (s)') 

674 ax[1, 0].plot(times, displacement_signal) 

675 ax[1, 0].set_ylabel('Displacement') 

676 ax[1, 0].set_xlabel('Time (s)') 

677 # Compute SRS 

678 if srs_frequencies is None: 

679 srs_frequencies = sine_frequencies 

680 this_srs, this_frequencies = srs( 

681 acceleration_signal, 1/sample_rate, srs_frequencies, 

682 srs_damping, srs_type) 

683 if srs_breakpoints is None: 

684 srs_abscissa = sine_frequencies 

685 srs_ordinate = required_srs 

686 else: 

687 srs_abscissa, srs_ordinate = srs_breakpoints.T 

688 ax[1, 1].plot(srs_abscissa, srs_ordinate, 'k--') 

689 ax[1, 1].plot(this_frequencies, this_srs) 

690 ax[1, 1].set_ylabel('SRS ({:0.2f}% damping)'.format(srs_damping*100)) 

691 ax[1, 1].set_xlabel('Frequency (Hz)') 

692 ax[1, 1].set_yscale('log') 

693 ax[1, 1].set_xscale('log') 

694 fig.tight_layout() 

695 return_vals += (fig, ax) 

696 ax[1, 1].legend(('Reference', 'Decayed Sine')) 

697 return return_vals 

698 

699 

700def _sum_decayed_sines(sine_frequencies, sine_amplitudes, 

701 sine_decays, sine_delays, 

702 required_srs, 

703 compensation_frequency, compensation_decay, 

704 sample_rate, block_size, 

705 srs_damping, srs_type, 

706 number_of_iterations=3, convergence=0.8, 

707 error_tolerance=0.05, ignore_compensation_pulse = False, 

708 verbose=False): 

709 """ 

710 Optimizes the amplitudes of sums of decayed sines 

711 

712 Parameters 

713 ---------- 

714 sine_frequencies : ndarray 

715 An array of frequencies for each sine tone 

716 sine_amplitudes : ndarray 

717 An array of amplitudes for each sine tone 

718 sine_decays : ndarray 

719 An array of decay values for each sine tone 

720 sine_delays : ndarray 

721 An array of delay values for each sine tone 

722 required_srs : ndarray 

723 An array of SRS values for each frequency in `sine_frequencies` 

724 compensation_frequency : float 

725 The frequency of the compensation pulse 

726 compensation_decay : float 

727 The decay value for the compensation pulse 

728 sample_rate : float 

729 The sample rate of the signal 

730 block_size : int 

731 The number of samples in the signal 

732 srs_damping : float 

733 Fraction of critical damping to use in the SRS calculation (e.g. you 

734 should specify 0.03 to represent 3%, not 3). The default is 0.03. 

735 srs_type : int 

736 The type of spectrum desired: 

737 If `spectrum_type` > 0 (pos) then the SRS will be a base 

738 acceleration-absolute acceleration model 

739 If `spectrum_type` < 0 (neg) then the SRS will be a base acceleration-relative 

740 displacement model (expressed in equivalent static acceleration units). 

741 If abs(`spectrum_type`) is: 

742 1--positive primary, 2--negative primary, 3--absolute maximum primary 

743 4--positive residual, 5--negative residual, 6--absolute maximum residual 

744 7--largest of 1&4, maximum positive, 8--largest of 2&5, maximum negative 

745 9 -- maximax, the largest absolute value of 1-8 

746 10 -- returns a matrix s(9,length(fn)) with all the types 1-9. 

747 number_of_iterations : int 

748 The number of iterations that will be performed. The default is 3. 

749 convergence : float, optional 

750 The fraction of the error corrected each iteration. The default is 0.8. 

751 error_tolerance : float, optional 

752 Allowable relative error in the SRS. The default is 0.05. 

753 ignore_compensation_pulse : bool, optional 

754 If True, ignores the compensation pulse. Default is false. 

755 verbose : bool, optional 

756 If True, information on the interations will be provided. The default 

757 is False. 

758 

759 Returns 

760 ------- 

761 this_signal : np.ndarray 

762 A numpy array containing the generated signal 

763 sine_frequencies : ndarray 

764 An array of frequencies for each sine tone 

765 sine_amplitudes : ndarray 

766 An array of amplitudes for each sine tone 

767 sine_decays : ndarray 

768 An array of decay values for each sine tone 

769 sine_delays : ndarray 

770 An array of delay values for each sine tone 

771 this_compensation_frequency : float 

772 The frequency of the compensation term. 

773 this_compensation_amplitude : float 

774 The amplitude of the compensation term. 

775 this_compensation_decay : float 

776 The decay constant of the compensation term 

777 this_compensation_delay : float 

778 The delay constant of the compenstation term 

779 """ 

780 for i in range(number_of_iterations): 

781 if verbose: 

782 print('Starting Iteration {:}'.format(i+1)) 

783 # Compute updated sine amplitudes and compensation terms 

784 this_sine_amplitudes, this_compensation_amplitude, this_compensation_delay = ( 

785 _sum_decayed_sines_single_iteration( 

786 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, 

787 required_srs, compensation_frequency, compensation_decay, 

788 sample_rate, block_size, srs_damping, srs_type, 

789 number_of_iterations=10, convergence=convergence, 

790 error_tolerance=error_tolerance, ignore_compensation_pulse=ignore_compensation_pulse, 

791 verbose=verbose)) 

792 # get the SRS error at each frequency term by first computing the signal 

793 (this_signal, this_compensation_frequency, this_compensation_amplitude, 

794 this_compensation_decay, this_compensation_delay) = sum_decayed_sines_reconstruction_with_compensation( 

795 sine_frequencies, this_sine_amplitudes, sine_decays, sine_delays, 

796 compensation_frequency, compensation_decay, sample_rate, block_size, ignore_compensation_pulse=ignore_compensation_pulse) 

797 # Then computing the SRS of the signal 

798 this_srs = srs(this_signal, 1/sample_rate, sine_frequencies, srs_damping, 

799 srs_type)[0] 

800 srs_error = (this_srs - required_srs)/required_srs 

801 sine_amplitudes = this_sine_amplitudes 

802 if verbose: 

803 print('Sine Table after Iterations:') 

804 print('{:>8s}, {:>10s}, {:>10s}, {:>10s}, {:>10s}, {:>10s}'.format( 

805 'Sine', 'Frequency', 'Amplitude', 'SRS Val', 'SRS Req', 'Error')) 

806 for i, (freq, amp, srs_val, srs_req, srs_err) in enumerate(zip( 

807 sine_frequencies, sine_amplitudes, this_srs, required_srs, 

808 srs_error)): 

809 print('{:>8s}, {:>10.4f}, {:>10.4f}, {:>10.4f}, {:>10.4f}, {:>10.4f}'.format( 

810 str(i), freq, amp, srs_val, srs_req, srs_err)) 

811 print('Compensating Pulse:') 

812 print('{:>10s}, {:>10s}, {:>10s}, {:>10s}'.format( 

813 'Frequency', 'Amplitude', 'Decay', 'Delay')) 

814 print('{:>10.4f}, {:>10.4f}, {:>10.4f}, {:>10.4f}'.format( 

815 this_compensation_frequency, this_compensation_amplitude, 

816 this_compensation_decay, this_compensation_delay)) 

817 return (this_signal, sine_frequencies, sine_amplitudes, sine_decays, 

818 sine_delays, this_compensation_frequency, this_compensation_amplitude, 

819 this_compensation_decay, this_compensation_delay) 

820 

821 

822def _sum_decayed_sines_single_iteration(sine_frequencies, sine_amplitudes, 

823 sine_decays, sine_delays, 

824 required_srs, compensation_frequency, 

825 compensation_decay, 

826 sample_rate, block_size, 

827 damping_srs, srs_type, 

828 number_of_iterations=10, convergence=0.8, 

829 error_tolerance=0.05, 

830 ignore_compensation_pulse=False, 

831 verbose=False): 

832 """ 

833 Iterates on amplitudes of decayed sine waves to match a prescribed SRS 

834 

835 Parameters 

836 ---------- 

837 sine_frequencies : ndarray 

838 An array of frequencies for each sine tone 

839 sine_amplitudes : ndarray 

840 An array of amplitudes for each sine tone 

841 sine_decays : ndarray 

842 An array of decay values for each sine tone 

843 sine_delays : ndarray 

844 An array of delay values for each sine tone 

845 required_srs : ndarray 

846 An array of SRS values for each frequency in `sine_frequencies` 

847 compensation_frequency : float 

848 The frequency of the compensation pulse 

849 compensation_decay : float 

850 The decay value for the compensation pulse 

851 sample_rate : float 

852 The sample rate of the signal 

853 block_size : int 

854 The number of samples in the signal 

855 damping_srs : float 

856 Fraction of critical damping to use in the SRS calculation (e.g. you 

857 should specify 0.03 to represent 3%, not 3). The default is 0.03. 

858 srs_type : int 

859 The type of spectrum desired: 

860 If `spectrum_type` > 0 (pos) then the SRS will be a base 

861 acceleration-absolute acceleration model 

862 If `spectrum_type` < 0 (neg) then the SRS will be a base acceleration-relative 

863 displacement model (expressed in equivalent static acceleration units). 

864 If abs(`spectrum_type`) is: 

865 1--positive primary, 2--negative primary, 3--absolute maximum primary 

866 4--positive residual, 5--negative residual, 6--absolute maximum residual 

867 7--largest of 1&4, maximum positive, 8--largest of 2&5, maximum negative 

868 9 -- maximax, the largest absolute value of 1-8 

869 10 -- returns a matrix s(9,length(fn)) with all the types 1-9. 

870 number_of_iterations : int 

871 Maximum number of iterations that can be performed at each frequency line. 

872 Default is 10. 

873 convergence : float, optional 

874 The fraction of the error corrected each iteration. The default is 0.8. 

875 error_tolerance : float, optional 

876 Allowable relative error in the SRS. The default is 0.05. 

877 ignore_compensation_pulse : bool, optional 

878 If True, do not use a compensation pulse. Default is false. 

879 verbose : bool, optional 

880 If True, information on the interations will be provided. The default 

881 is False. 

882 

883 Raises 

884 ------ 

885 ValueError 

886 If compensation delay is required to be too long. 

887 

888 Returns 

889 ------- 

890 sine_amplitudes : np.ndarray 

891 An array the same size as the input `sine_amplitudes` array with updated 

892 amplitudes. 

893 compensation_amplitude : float 

894 Amplitude of the compensation pulse 

895 compensation_delay : float 

896 Delay of the compensation pulse. 

897 """ 

898 # Define some helper variables 

899 Amax = np.max(np.abs(sine_amplitudes)) 

900 # Reduce the error tolerance so there's a bit of room for round-off 

901 error_tolerance = error_tolerance*0.9 

902 # Get filter weights for SRS calculations 

903 b, a = sdof_ramp_invariant_filter_weights(sine_frequencies, sample_rate, 

904 damping_srs, srs_type) 

905 # Copy the arrays so we don't overwrite anything 

906 sine_frequencies = np.array(sine_frequencies).copy() 

907 sine_amplitudes = np.array(sine_amplitudes).copy() 

908 sine_decays = np.array(sine_decays).copy() 

909 sine_delays = np.array(sine_delays).copy() 

910 # Iterate one frequency at a time 

911 for i, (frequency, amplitude, decay, delay, desired_srs) in enumerate(zip( 

912 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, 

913 required_srs)): 

914 # if i == 10: 

915 # break 

916 srs_error = float('inf') 

917 iteration_count = 1 

918 increment = 0.1 

919 # Get the pulse without this frequency line 

920 other_sine_frequencies = np.concatenate((sine_frequencies[:i], sine_frequencies[i+1:])) 

921 other_sine_amplitudes = np.concatenate((sine_amplitudes[:i], sine_amplitudes[i+1:])) 

922 other_sine_decays = np.concatenate((sine_decays[:i], sine_decays[i+1:])) 

923 other_sine_delays = np.concatenate((sine_delays[:i], sine_delays[i+1:])) 

924 other_pulse = sum_decayed_sines_reconstruction( 

925 other_sine_frequencies, other_sine_amplitudes, other_sine_decays, 

926 other_sine_delays, sample_rate, block_size) 

927 # Now iterate at the single frequency until convergence 

928 while abs(srs_error) > error_tolerance: 

929 sine_amplitudes[i] = amplitude 

930 # Find the pulse at this frequency line 

931 this_pulse = sum_decayed_sines_reconstruction( 

932 frequency, amplitude, decay, delay, sample_rate, block_size) 

933 # Get the compensating pulse 

934 if not ignore_compensation_pulse: 

935 compensation_amplitude, compensation_delay = sum_decayed_sines_compensating_pulse_parameters( 

936 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, compensation_frequency, compensation_decay) 

937 else: 

938 compensation_amplitude = 0 

939 compensation_delay = 0 

940 # Find the number of samples to shift the waveform 

941 num_shift = -int(np.floor(compensation_delay*sample_rate)) 

942 if abs(num_shift) >= block_size: 

943 raise ValueError('The number of samples in the compensation delay ({:}) is larger than the block_size ({:}).'.format(num_shift, block_size) 

944 + ' The entire pulse will consist of part of the compensation pulse.' 

945 + ' Please increase the block_size or compensation frequency.') 

946 compensation_delay_corrected = compensation_delay + num_shift/sample_rate 

947 # Find compensating time history 

948 compensation_pulse = sum_decayed_sines_reconstruction( 

949 compensation_frequency, compensation_amplitude, compensation_decay, 

950 compensation_delay_corrected, sample_rate, block_size) 

951 # Build the composite waveform, need to shift the signals to align 

952 if num_shift >= 0: 

953 composite_pulse = ( 

954 compensation_pulse 

955 + np.concatenate(( 

956 np.zeros(num_shift), 

957 (other_pulse + this_pulse)[:block_size-num_shift]))) 

958 else: 

959 composite_pulse = ( 

960 other_pulse + this_pulse + 

961 + np.concatenate(( 

962 np.zeros(-num_shift), 

963 (compensation_pulse)[:block_size+num_shift]))) 

964 # Find the SRS at the current frequency line 

965 srs_prediction = srs(composite_pulse, 1/sample_rate, frequency, damping_srs, 

966 srs_type, b[i], a[i])[0][0] # only get the SRS and there should only be one value due to one frequency line 

967 srs_error = (srs_prediction - desired_srs)/desired_srs 

968 if verbose: 

969 print('Iteration at frequency {:}, {:0.4f}\n Iteration Count: {:}, SRS Error: {:0.4f}'.format(i, frequency, iteration_count, srs_error)) 

970 # Now we're going to compute the same thing again with a perturbed 

971 # amplitude, this will allow us to compute the slope change at the 

972 # current amplitude 

973 amplitude_change = np.sign(srs_error)*increment*np.sign(amplitude)*Amax 

974 # Now check and see if we need to modify the amplitude 

975 if abs(srs_error) > error_tolerance: 

976 if amplitude_change == 0: # perturb it a bit 

977 amplitude_change = np.sign(srs_error)*np.sign(sine_amplitudes[i])*Amax*increment/10 

978 new_amplitude = amplitude + amplitude_change 

979 # Can't allow the sign of the amplitude to change 

980 if np.sign(amplitude) != np.sign(new_amplitude): 

981 new_amplitude *= -1 

982 if amplitude == new_amplitude: 

983 new_amplitude = amplitude * 1.01 

984 sine_amplitudes[i] = new_amplitude 

985 # Find new component at this frequency 

986 this_pulse = sum_decayed_sines_reconstruction( 

987 frequency, new_amplitude, decay, delay, sample_rate, block_size) 

988 # Get the compensating pulse 

989 if not ignore_compensation_pulse: 

990 compensation_amplitude, compensation_delay = sum_decayed_sines_compensating_pulse_parameters( 

991 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, compensation_frequency, compensation_decay) 

992 else: 

993 compensation_amplitude = 0 

994 compensation_delay = 0 

995 # Find the number of samples to shift the waveform 

996 num_shift = -int(np.floor(compensation_delay*sample_rate)) 

997 if abs(num_shift) >= block_size: 

998 raise ValueError('The number of samples in the compensation delay ({:}) is larger than the block_size ({:}).'.format(num_shift, block_size) 

999 + ' The entire pulse will consist of part of the compensation pulse. ' 

1000 + 'Please increase the block_size or compensation frequency.') 

1001 compensation_delay_corrected = compensation_delay + num_shift/sample_rate 

1002 # Find compensating time history 

1003 compensation_pulse = sum_decayed_sines_reconstruction( 

1004 compensation_frequency, compensation_amplitude, compensation_decay, 

1005 compensation_delay_corrected, sample_rate, block_size) 

1006 # Build the composite waveform, need to shift the signals to align 

1007 if num_shift >= 0: 

1008 composite_pulse = ( 

1009 compensation_pulse 

1010 + np.concatenate(( 

1011 np.zeros(num_shift), 

1012 (other_pulse + this_pulse)[:block_size-num_shift]))) 

1013 else: 

1014 composite_pulse = ( 

1015 other_pulse + this_pulse + 

1016 + np.concatenate(( 

1017 np.zeros(-num_shift), 

1018 (compensation_pulse)[:block_size+num_shift]))) 

1019 # Find the SRS at the current frequency line 

1020 srs_perturbed = srs(composite_pulse, 1/sample_rate, frequency, damping_srs, 

1021 srs_type, b[i], a[i])[0][0] # only get the SRS and there should only be one value due to one frequency line 

1022 # Get slope of correction 

1023 correction_slope = (abs(amplitude)-abs(new_amplitude))/(srs_prediction-srs_perturbed) 

1024 if correction_slope > 0: 

1025 # this is what we want, it means we are changing in the right 

1026 # direction 

1027 new_amplitude = convergence*correction_slope*(desired_srs-srs_prediction) + abs(amplitude) 

1028 # Never let the change be too large 

1029 amplitude_check = max([abs(amplitude), Amax/10]) 

1030 if new_amplitude > 2*amplitude_check: 

1031 new_amplitude = 2*amplitude_check 

1032 # Never let it be less than zero 

1033 if new_amplitude < 0: 

1034 new_amplitude = np.abs(amplitude)/10 

1035 # Move the previous amplitude into a different variable 

1036 old_amplitude = amplitude 

1037 # Never allow new amplitude to be zero 

1038 if new_amplitude == 0: 

1039 amplitude = 2*np.finfo(float).eps*np.sign(amplitude) 

1040 else: 

1041 amplitude = new_amplitude*np.sign(amplitude) 

1042 # Store it for the next iteration 

1043 sine_amplitudes[i] = amplitude 

1044 # Find new component at this frequency 

1045 this_pulse = sum_decayed_sines_reconstruction( 

1046 frequency, amplitude, decay, delay, sample_rate, block_size) 

1047 # Get the compensating pulse 

1048 if not ignore_compensation_pulse: 

1049 compensation_amplitude, compensation_delay = sum_decayed_sines_compensating_pulse_parameters( 

1050 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, compensation_frequency, compensation_decay) 

1051 else: 

1052 compensation_amplitude = 0 

1053 compensation_delay = 0 

1054 # Find the number of samples to shift the waveform 

1055 num_shift = -int(np.floor(compensation_delay*sample_rate)) 

1056 if abs(num_shift) >= block_size: 

1057 raise ValueError('The number of samples in the compensation delay' 

1058 + ' ({:}) is larger than the block_size ({:}).'.format(num_shift, block_size) 

1059 + ' The entire pulse will consist of part of the compensation pulse. ' 

1060 + 'Please increase the block_size or compensation frequency.') 

1061 compensation_delay_corrected = compensation_delay + num_shift/sample_rate 

1062 # Find compensating time history 

1063 compensation_pulse = sum_decayed_sines_reconstruction( 

1064 compensation_frequency, compensation_amplitude, compensation_decay, 

1065 compensation_delay_corrected, sample_rate, block_size) 

1066 # Build the composite waveform, need to shift the signals to align 

1067 if num_shift >= 0: 

1068 composite_pulse = ( 

1069 compensation_pulse 

1070 + np.concatenate(( 

1071 np.zeros(num_shift), 

1072 (other_pulse + this_pulse)[:block_size-num_shift]))) 

1073 else: 

1074 composite_pulse = ( 

1075 other_pulse + this_pulse + 

1076 + np.concatenate(( 

1077 np.zeros(-num_shift), 

1078 (compensation_pulse)[:block_size+num_shift]))) 

1079 # Find the SRS at the current frequency line 

1080 srs_corrected = srs(composite_pulse, 1/sample_rate, frequency, damping_srs, 

1081 srs_type, b[i], a[i])[0][0] # only get the SRS and there should only be one value due to one frequency line 

1082 srs_error = (srs_corrected - desired_srs)/desired_srs 

1083 # If the SRS error is positive and the amplitude has already been reduced to zero, stop trying 

1084 if srs_error > 0 and amplitude == 0: 

1085 iteration_count = number_of_iterations + 1 

1086 if verbose: 

1087 print(' Old Amplitude: {:0.4f}, New Amplitude: {:0.4f}, Error: {:0.4f}'.format(old_amplitude, amplitude, srs_error)) 

1088 # Don't allow amplitude to change more than a factor of 10 in an iteration 

1089 if np.abs(10*old_amplitude) < np.abs(amplitude) or np.abs(0.1*old_amplitude) > np.abs(amplitude): 

1090 iteration_count = number_of_iterations + 1 

1091 else: 

1092 # The slope is negative, so we try a bigger increment 

1093 increment *= 2 

1094 amplitude_change = np.sign(srs_error)*increment*np.sign(amplitude)*Amax 

1095 if verbose: 

1096 print('Slope of correction was negative, trying a bigger increment') 

1097 iteration_count += 1 

1098 if iteration_count > number_of_iterations: 

1099 print(' Warning: SRS did not converge for frequency {:}: {:0.4f}'.format(i, frequency)) 

1100 break 

1101 if not ignore_compensation_pulse: 

1102 compensation_amplitude, compensation_delay = sum_decayed_sines_compensating_pulse_parameters( 

1103 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, compensation_frequency, compensation_decay) 

1104 else: 

1105 compensation_amplitude = 0 

1106 compensation_delay = 0 

1107 return sine_amplitudes, compensation_amplitude, compensation_delay 

1108 

1109 

1110def sum_decayed_sines_compensating_pulse_parameters(sine_frequencies, sine_amplitudes, sine_decays, sine_delays, 

1111 compensation_frequency, compensation_decay): 

1112 omegas = sine_frequencies*2*np.pi 

1113 omega_comp = compensation_frequency*2*np.pi 

1114 var = -np.sum(sine_amplitudes/(sine_frequencies*(sine_decays**2+1))) 

1115 compensation_amplitude = compensation_frequency*(compensation_decay**2+1)*var 

1116 var0 = (np.sum(sine_amplitudes*sine_delays/(omegas*(sine_decays**2+1))))/compensation_amplitude 

1117 # TODO Verify if this is a bug or not, I think this should be matlab code but graflab code had it as a matrix division. 

1118 # var1 = (np.sum(2*sine_decays*sine_amplitudes/(omegas**2*(sine_decays**2 + 1)**2)))/compensation_amplitude 

1119 var1 = (np.sum((2*sine_decays*sine_amplitudes)[np.newaxis, :]@np.linalg.pinv((omegas**2*(sine_decays**2 + 1)**2)[np.newaxis, :])))/compensation_amplitude 

1120 var2 = 2*compensation_decay/(omega_comp*omega_comp*(compensation_decay**2+1)**2) 

1121 var3 = omega_comp*(compensation_decay**2+1) 

1122 compensation_delay = -var3*(var2 + var1 + var0) 

1123 return compensation_amplitude, compensation_delay 

1124 

1125 

1126def sum_decayed_sines_reconstruction(sine_frequencies, sine_amplitudes, 

1127 sine_decays, sine_delays, sample_rate, 

1128 block_size): 

1129 """ 

1130 Computes a sum of decayed sines signal from a set of frequencies, amplitudes, 

1131 decays, and delays. 

1132 

1133 Parameters 

1134 ---------- 

1135 sine_frequencies : ndarray 

1136 An array of frequencies for each sine tone 

1137 sine_amplitudes : ndarray 

1138 An array of amplitudes for each sine tone 

1139 sine_decays : ndarray 

1140 An array of decay values for each sine tone 

1141 sine_delays : ndarray 

1142 An array of delay values for each sine tone 

1143 sample_rate : float 

1144 The sample rate of the signal 

1145 block_size : int 

1146 The number of samples in the signal 

1147 

1148 Returns 

1149 ------- 

1150 ndarray 

1151 A signal containing the sum of decayed sinusoids. 

1152 

1153 """ 

1154 times = np.arange(block_size)/sample_rate 

1155 # See if any delays are below zero and if so then shift all delays forward 

1156 if np.any(sine_delays < 0): 

1157 sine_delays = sine_delays - np.min(sine_delays) 

1158 # Now go through and compute the sine tones 

1159 omegas = 2*np.pi*sine_frequencies 

1160 this_times = times[:, np.newaxis] - sine_delays 

1161 response = sine_amplitudes*np.exp(-sine_decays*omegas*this_times)*np.sin(omegas*this_times) 

1162 response[this_times < 0] = 0 

1163 return np.sum(response, axis=-1) 

1164 

1165 

1166def sum_decayed_sines_reconstruction_with_compensation( 

1167 sine_frequencies, sine_amplitudes, 

1168 sine_decays, sine_delays, compensation_frequency, compensation_decay, 

1169 sample_rate, block_size, ignore_compensation_pulse = False): 

1170 """ 

1171 

1172 

1173 Parameters 

1174 ---------- 

1175 sine_frequencies : ndarray 

1176 An array of frequencies for each sine tone 

1177 sine_amplitudes : ndarray 

1178 An array of amplitudes for each sine tone 

1179 sine_decays : ndarray 

1180 An array of decay values for each sine tone 

1181 sine_delays : ndarray 

1182 An array of delay values for each sine tone 

1183 compensation_frequency : float 

1184 The frequency of the compensation pulse 

1185 compensation_decay : float 

1186 The decay value for the compensation pulse 

1187 sample_rate : float 

1188 The sample rate of the signal 

1189 block_size : int 

1190 The number of samples in the signal 

1191 ignore_compensation_pulse : bool, optional 

1192 If True, ignores the compensation pulse 

1193 

1194 Returns 

1195 ------- 

1196 signal : ndarray 

1197 A signal containing the sum of decayed sinusoids. 

1198 compensation_frequency : float 

1199 The frequency of the compensation pulse 

1200 compensation_amplitude : float 

1201 The amplitude value for the compensation pulse 

1202 compensation_decay : float 

1203 The decay value for the compensation pulse 

1204 compensation_delay : float 

1205 The delay value for the compensation pulse 

1206 """ 

1207 sine_frequencies = np.array(sine_frequencies).flatten() 

1208 sine_amplitudes = np.array(sine_amplitudes).flatten() 

1209 sine_delays = np.array(sine_delays).flatten() 

1210 sine_decays = np.array(sine_decays).flatten() 

1211 if not ignore_compensation_pulse: 

1212 compensation_amplitude, compensation_delay = sum_decayed_sines_compensating_pulse_parameters( 

1213 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, 

1214 compensation_frequency, compensation_decay) 

1215 else: 

1216 compensation_amplitude = 0 

1217 compensation_delay = 0 

1218 sine_frequencies = np.concatenate((sine_frequencies, [compensation_frequency])) 

1219 sine_amplitudes = np.concatenate((sine_amplitudes, [compensation_amplitude])) 

1220 sine_delays = np.concatenate((sine_delays, [compensation_delay])) 

1221 sine_decays = np.concatenate((sine_decays, [compensation_decay])) 

1222 signal = sum_decayed_sines_reconstruction( 

1223 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, 

1224 sample_rate, block_size) 

1225 return (signal, compensation_frequency, compensation_amplitude, 

1226 compensation_decay, compensation_delay) 

1227 

1228 

1229def sum_decayed_sines_displacement_velocity( 

1230 sine_frequencies, sine_amplitudes, 

1231 sine_decays, sine_delays, sample_rate, block_size, 

1232 acceleration_factor=1): 

1233 """ 

1234 Creates velocity and displacement signals from acceleration sinusoids. 

1235 

1236 Parameters 

1237 ---------- 

1238 sine_frequencies : ndarray 

1239 An array of frequencies for each sine tone 

1240 sine_amplitudes : ndarray 

1241 An array of amplitudes for each sine tone 

1242 sine_decays : ndarray 

1243 An array of decay values for each sine tone 

1244 sine_delays : ndarray 

1245 An array of delay values for each sine tone 

1246 sample_rate : float 

1247 The sample rate of the signal 

1248 block_size : int 

1249 The number of samples in the signal 

1250 acceleration_factor : float, optional 

1251 Optional scale factor to convert acceleration into velocity and 

1252 displacement. For example, if sine amplitudes are in G and displacement 

1253 is desired in inches, the acceleration factor should be set to 386.089. 

1254 If sine amplitudes are in G and displacement is desired in meters, the 

1255 acceleration factor should be set to 9.80665. The default is 1, which 

1256 assumes consistent units (e.g. acceleration in m/s^2, velocity in m/s, 

1257 displacement in m). 

1258 

1259 Returns 

1260 ------- 

1261 v : ndarray 

1262 The velocity of the signal. 

1263 d : ndarray 

1264 The displacement of the signal. 

1265 

1266 """ 

1267 # Make sure everything is a numpy array 

1268 sine_frequencies = np.array(sine_frequencies).flatten() 

1269 sine_amplitudes = np.array(sine_amplitudes).flatten() 

1270 sine_delays = np.array(sine_delays).flatten() 

1271 sine_decays = np.array(sine_decays).flatten() 

1272 # Transform units 

1273 sine_amplitudes = sine_amplitudes * acceleration_factor 

1274 # See if any delays are below zero and if so then shift all delays forward 

1275 if np.any(sine_delays < 0): 

1276 sine_delays = sine_delays - np.min(sine_delays) 

1277 f = sine_frequencies 

1278 A = sine_amplitudes 

1279 z = sine_decays 

1280 tau = sine_delays 

1281 x = np.zeros(block_size) 

1282 tmp1 = x.copy() 

1283 tmp2 = x.copy() 

1284 tmp3 = x.copy() 

1285 x1 = np.ones(x.shape) 

1286 v = x.copy() 

1287 d = x.copy() 

1288 w = 2*np.pi*f 

1289 w2 = w*w 

1290 zw = z*w 

1291 zp1 = z*z + 1 

1292 zm1 = z*z - 1 

1293 t = np.arange(block_size)/sample_rate 

1294 for k in range(len(sine_frequencies)): 

1295 indices = t-tau[k] > 0 # index's where vel and disp are evaluated 

1296 Awz1 = A[k]/(w[k]*zp1[k]) 

1297 tmp1[indices] = Awz1*np.exp(-zw[k]*(t[indices]-tau[k])) 

1298 tmp2[indices] = z[k]*np.sin(w[k]*(t[indices]-tau[k])) + np.cos(w[k]*(t[indices]-tau[k])) 

1299 v[indices] = v[indices] - tmp1[indices]*tmp2[indices] + Awz1*x1[indices] 

1300 Awz2 = A[k]/(w2[k]*zp1[k]*zp1[k]) 

1301 tmp1[indices] = Awz2*np.exp(-zw[k]*(t[indices]-tau[k])) 

1302 tmp2[indices] = zm1[k]*np.sin(w[k]*(t[indices]-tau[k])) + 2*z[k]*np.cos(w[k]*(t[indices]-tau[k])) 

1303 tmp3[indices] = Awz1*(t[indices]-tau[k]) 

1304 tmp4 = 2*z[k]*Awz2 

1305 d[indices] = d[indices] + tmp1[indices]*tmp2[indices] + tmp3[indices] - tmp4 

1306 return v, d 

1307 

1308 

1309def loginterp(x, xp, fp): 

1310 return 10**np.interp(np.log10(x), np.log10(xp), np.log10(fp)) 

1311 

1312 

1313def optimization_error_function( 

1314 amplitude_lin_scales, sample_rate, block_size, 

1315 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, 

1316 compensation_frequency, compensation_decay, control_irfs, limit_irfs, 

1317 srs_damping, srs_type, control_srs, control_weights, limit_srs, 

1318 b=None, a=None, frequency_index=None): 

1319 if frequency_index is None: 

1320 frequency_index = slice(None) 

1321 # Apply the scale factors 

1322 new_sine_amplitudes = sine_amplitudes.copy() 

1323 new_sine_amplitudes[frequency_index] *= amplitude_lin_scales 

1324 # Compute a new time history signal 

1325 (input_signal, new_compensation_frequency, new_compensation_amplitude, 

1326 new_compensation_decay, new_compensation_delay) = sum_decayed_sines_reconstruction_with_compensation( 

1327 sine_frequencies, new_sine_amplitudes, sine_decays, sine_delays, 

1328 compensation_frequency, compensation_decay, sample_rate, block_size) 

1329 # Transform the signal to the control degrees of freedom 

1330 control_responses = np.array([oaconvolve(control_irf, input_signal) for control_irf in control_irfs])[..., :block_size] 

1331 # Compute SRSs at all frequencies 

1332 predicted_control_srs, frequencies = srs(control_responses, 1/sample_rate, sine_frequencies[frequency_index], 

1333 srs_damping, srs_type, 

1334 None if b is None else b[frequency_index], 

1335 None if a is None else a[frequency_index]) 

1336 # Compute error 

1337 mean_control_error = np.mean( 

1338 (control_weights[:, np.newaxis] 

1339 * ((predicted_control_srs - control_srs[..., frequency_index]) 

1340 / control_srs[..., frequency_index]))) 

1341 if limit_srs is not None: 

1342 limit_responses = np.array([oaconvolve(limit_irf, input_signal) for limit_irf in limit_irfs])[..., :block_size] 

1343 predicted_limit_srs, frequencies = srs(limit_responses, 1/sample_rate, sine_frequencies[frequency_index], 

1344 srs_damping, srs_type, 

1345 None if b is None else b[frequency_index], 

1346 None if a is None else a[frequency_index]) 

1347 max_limit_error = np.max( 

1348 ((predicted_limit_srs - limit_srs[..., frequency_index]) 

1349 / limit_srs[..., frequency_index])) 

1350 if max_limit_error >= 0 and mean_control_error >= 0: 

1351 srs_error = np.max((mean_control_error, max_limit_error)) 

1352 elif max_limit_error <= 0 and mean_control_error <= 0: 

1353 srs_error = np.max((mean_control_error, max_limit_error)) 

1354 elif max_limit_error >= 0 and mean_control_error <= 0: 

1355 srs_error = max_limit_error 

1356 elif max_limit_error <= 0 and mean_control_error >= 0: 

1357 srs_error = mean_control_error 

1358 else: 

1359 limit_responses = None 

1360 predicted_limit_srs = None 

1361 srs_error = mean_control_error 

1362 

1363 return (np.abs(srs_error), input_signal, control_responses, 

1364 predicted_control_srs, limit_responses, predicted_limit_srs, 

1365 new_sine_amplitudes, new_compensation_frequency, 

1366 new_compensation_amplitude, new_compensation_decay, 

1367 new_compensation_delay) 

1368 

1369 

1370def optimization_callback(intermediate_result, rms_error_threshold=0.02, 

1371 verbose=True): 

1372 if verbose: 

1373 print('Amplitude Scale: {:}, error is {:}'.format(intermediate_result.x.squeeze(), 

1374 intermediate_result.fun)) 

1375 if rms_error_threshold is not None: 

1376 if intermediate_result.fun < rms_error_threshold: 

1377 raise StopIteration 

1378 

1379 

1380def sum_decayed_sines_minimize(sample_rate, block_size, 

1381 sine_frequencies=None, sine_tone_range=None, sine_tone_per_octave=None, 

1382 sine_amplitudes=None, sine_decays=None, sine_delays=None, 

1383 control_srs=None, control_breakpoints=None, 

1384 srs_damping=0.05, srs_type=9, 

1385 compensation_frequency=None, compensation_decay=0.95, 

1386 # Parameters for defining decays 

1387 tau=None, num_time_constants=None, decay_resolution=None, 

1388 scale_factor=1.02, 

1389 acceleration_factor=1.0, 

1390 # Parameters for imposing limits 

1391 limit_breakpoints=None, limit_transfer_functions=None, 

1392 control_transfer_functions=None, control_weights=None, 

1393 # Parameters for the optimizer 

1394 minimize_iterations=1, rms_error_threshold=None, 

1395 optimization_passes=3, 

1396 plot_results=False, verbose=False 

1397 ): 

1398 # Handle the sine tone frequencies 

1399 if sine_frequencies is None and sine_tone_range is None: 

1400 raise ValueError('Either `sine_frequencies` or `sine_tone_range` must be specified') 

1401 if sine_frequencies is not None and sine_tone_range is not None: 

1402 raise ValueError('`sine_frequencies` can not be specified simultaneously with `sine_tone_range`') 

1403 if sine_frequencies is None: 

1404 # Create sine tones 

1405 if sine_tone_per_octave is None: 

1406 sine_tone_per_octave = int(np.floor(9-srs_damping*100)) 

1407 sine_frequencies = octspace(sine_tone_range[0], sine_tone_range[1], 

1408 sine_tone_per_octave) 

1409 # Now set up the SRS 

1410 if control_srs is None and control_breakpoints is None: 

1411 raise ValueError('Either `control_srs` or `control_breakpoints` must be specified') 

1412 if control_srs is not None and control_breakpoints is not None: 

1413 raise ValueError('`control_srs` can not be specified simultaneously with `control_breakpoints`') 

1414 if control_srs is None: 

1415 frequencies = control_breakpoints[:, 0] 

1416 breakpoint_curves = control_breakpoints[:, 1:].T 

1417 control_srs = np.array([loginterp(sine_frequencies, 

1418 frequencies, 

1419 breakpoint_curve) for breakpoint_curve in breakpoint_curves]) 

1420 else: 

1421 control_srs = np.atleast_2d(control_srs) 

1422 if control_transfer_functions is None: 

1423 control_transfer_functions = np.ones(((control_srs.shape[0]-1)*2, block_size//2+1)) 

1424 tf_frequencies = np.fft.rfftfreq(control_transfer_functions.shape[-1]*2-1, 1/sample_rate) 

1425 if sine_amplitudes is None: 

1426 tf_at_frequencies = np.array([ 

1427 np.interp(sine_frequencies, tf_frequencies, np.abs(control_transfer_function)) 

1428 for control_transfer_function in control_transfer_functions]) 

1429 srs_amplitudes = np.array([nnls(ai[:, np.newaxis], bi)[0][0] for ai, bi in zip(tf_at_frequencies.T, control_srs.T)]) 

1430 srs_amplitudes[np.arange(srs_amplitudes.size) % 2 == 0] *= -1 

1431 quality = 1/(2*srs_damping) 

1432 srs_amplitudes /= quality 

1433 sine_amplitudes = srs_amplitudes 

1434 

1435 if sine_delays is None: 

1436 sine_delays = np.zeros(sine_frequencies.size) 

1437 

1438 if compensation_frequency is None: 

1439 compensation_frequency = np.min(sine_frequencies)/3 

1440 

1441 # Set up decay terms 

1442 decay_terms_specified = 0 

1443 if sine_decays is not None: 

1444 decay_terms_specified += 1 

1445 if tau is not None: 

1446 decay_terms_specified += 1 

1447 if num_time_constants is not None: 

1448 decay_terms_specified += 1 

1449 if decay_terms_specified == 0: 

1450 raise ValueError('One of `sine_decays`, `tau`, or `num_time_constants` must be specified') 

1451 if decay_terms_specified > 1: 

1452 raise ValueError('Only one of `sine_decays`, `tau`, or `num_time_constants` can be specified') 

1453 

1454 # Now check and see which is defined 

1455 if num_time_constants is not None: 

1456 period = block_size / sample_rate 

1457 if isinstance(num_time_constants, dict): 

1458 tau = {} 

1459 for freq_range, num_time_constant in num_time_constants.items(): 

1460 tau[freq_range] = period / num_time_constant 

1461 else: 

1462 tau = period/num_time_constants 

1463 if tau is not None: 

1464 sine_decays = [] 

1465 for freq in sine_frequencies: 

1466 if isinstance(tau, dict): 

1467 this_decay = None 

1468 for freq_range, this_tau in tau.items(): 

1469 if freq_range[0] <= freq <= freq_range[1]: 

1470 this_decay = 1/(2*np.pi*freq*this_tau) 

1471 break 

1472 if this_decay is None: 

1473 raise ValueError('No frequency range matching frequency {:} was found in the specified decay parameters.'.format(freq)) 

1474 sine_decays.append(this_decay) 

1475 else: 

1476 sine_decays.append(1/(2*np.pi*freq*tau)) 

1477 sine_decays = np.array(sine_decays) 

1478 # Otherwise we just keep the specified sine_decays 

1479 

1480 # Now handle the minimum resolution on the decay 

1481 if decay_resolution is not None: 

1482 sine_decays = decay_resolution*np.round(sine_decays/decay_resolution) 

1483 

1484 if compensation_frequency is None: 

1485 compensation_frequency = np.min(sine_frequencies)/3 

1486 

1487 # Now set up limits and transfer functions 

1488 if control_weights is None: 

1489 control_weights = np.ones((control_srs.shape[0])) 

1490 if limit_breakpoints is not None: 

1491 frequencies = limit_breakpoints[:, 0] 

1492 breakpoint_curves = limit_breakpoints[:, 1:].T 

1493 limit_srs = np.array([loginterp(sine_frequencies, 

1494 frequencies, 

1495 breakpoint_curve) for breakpoint_curve in breakpoint_curves]) 

1496 else: 

1497 limit_srs = None 

1498 

1499 # Compute impulse responses 

1500 control_irfs = np.fft.irfft(control_transfer_functions, axis=-1) 

1501 if limit_transfer_functions is not None: 

1502 limit_irfs = np.fft.irfft(limit_transfer_functions, axis=-1) 

1503 else: 

1504 limit_irfs = None 

1505 b, a = sdof_ramp_invariant_filter_weights(sine_frequencies, sample_rate, srs_damping, srs_type) 

1506 

1507 # Normalize control weights 

1508 control_weights = control_weights/np.linalg.norm(control_weights) 

1509 

1510 # Copy things so we don't overwrite 

1511 sine_amplitudes = sine_amplitudes.copy() 

1512 

1513 # Now we will iterate over all of the frequencies and compute the new 

1514 # amplitudes 

1515 for j in range(optimization_passes): 

1516 for i in range(len(sine_frequencies)): 

1517 if verbose: 

1518 print('Pass {:}, Analyzing Frequency {:}: {:0.2f}'.format(j+1, i, sine_frequencies[i])) 

1519 

1520 # We will now go through and optimize the frequency line 

1521 def error_function(x): 

1522 return optimization_error_function( 

1523 x, sample_rate, block_size, 

1524 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, 

1525 compensation_frequency, compensation_decay, control_irfs, limit_irfs, 

1526 srs_damping, srs_type, control_srs, control_weights, limit_srs, 

1527 b, a, frequency_index=[i])[0] 

1528 

1529 def callback(intermediate_result): 

1530 return optimization_callback( 

1531 intermediate_result, rms_error_threshold, verbose) 

1532 

1533 optimization_result = minimize(error_function, 

1534 np.ones(1), 

1535 method='Powell', 

1536 bounds=[(0, np.inf)], 

1537 callback=callback, 

1538 options={'maxiter': minimize_iterations}) 

1539 

1540 # Populate the amplitudes with the updated result 

1541 if verbose: 

1542 print('Initial Amplitude: {:}, Updated Amplitude: {:}\n'.format(sine_amplitudes[i], sine_amplitudes[i]*optimization_result.x.squeeze())) 

1543 sine_amplitudes[i] *= optimization_result.x.squeeze() 

1544 

1545 (error, input_signal, control_responses, predicted_control_srs, 

1546 limit_responses, predicted_limit_srs, 

1547 sine_amplitudes, compensation_frequency, 

1548 compensation_amplitude, compensation_decay, 

1549 compensation_delay) = optimization_error_function( 

1550 np.ones(sine_frequencies.size), 

1551 sample_rate, block_size, 

1552 sine_frequencies, sine_amplitudes, sine_decays, sine_delays, 

1553 compensation_frequency, compensation_decay, control_irfs, limit_irfs, 

1554 srs_damping, srs_type, control_srs, control_weights, limit_srs, 

1555 b, a) 

1556 

1557 all_frequencies = np.concatenate((sine_frequencies, 

1558 [compensation_frequency])) 

1559 all_amplitudes = np.concatenate((sine_amplitudes, 

1560 [compensation_amplitude])) 

1561 all_decays = np.concatenate((sine_decays, 

1562 [compensation_decay])) 

1563 all_delays = np.concatenate((sine_delays, 

1564 [compensation_delay])) 

1565 

1566 # Now compute displacement and velocity 

1567 velocity_signal, displacement_signal = sum_decayed_sines_displacement_velocity( 

1568 all_frequencies, all_amplitudes, all_decays, all_delays, sample_rate, 

1569 block_size, acceleration_factor) 

1570 

1571 return_vals = (input_signal, velocity_signal, displacement_signal, 

1572 control_responses, predicted_control_srs, 

1573 limit_responses, predicted_limit_srs, 

1574 all_frequencies, all_amplitudes, all_decays, all_delays, 

1575 ) 

1576 

1577 if plot_results: 

1578 fig, ax = plt.subplots(2, 2, figsize=(8, 6)) 

1579 times = np.arange(block_size)/sample_rate 

1580 ax[0, 0].plot(times, input_signal) 

1581 ax[0, 0].set_ylabel('Acceleration') 

1582 ax[0, 0].set_xlabel('Time (s)') 

1583 ax[0, 1].plot(times, velocity_signal) 

1584 ax[0, 1].set_ylabel('Velocity') 

1585 ax[0, 1].set_xlabel('Time (s)') 

1586 ax[1, 0].plot(times, displacement_signal) 

1587 ax[1, 0].set_ylabel('Displacement') 

1588 ax[1, 0].set_xlabel('Time (s)') 

1589 # Compute SRS 

1590 this_srs, this_frequencies = srs( 

1591 input_signal, 1/sample_rate, sine_frequencies, 

1592 srs_damping, srs_type) 

1593 if control_breakpoints is None: 

1594 srs_abscissa = sine_frequencies 

1595 srs_ordinate = control_srs.T 

1596 else: 

1597 srs_abscissa = control_breakpoints[:, 0] 

1598 srs_ordinate = control_breakpoints[:, 1:] 

1599 ax[1, 1].plot(srs_abscissa, srs_ordinate, 'k--') 

1600 ax[1, 1].plot(this_frequencies, this_srs) 

1601 ax[1, 1].set_ylabel('SRS ({:0.2f}% damping)'.format(srs_damping*100)) 

1602 ax[1, 1].set_xlabel('Frequency (Hz)') 

1603 ax[1, 1].set_yscale('log') 

1604 ax[1, 1].set_xscale('log') 

1605 ax[1, 1].legend(('Reference', 'Decayed Sine')) 

1606 fig.tight_layout() 

1607 return_vals += (fig, ax) 

1608 # Now plot all control channels 

1609 for i, (predicted, control, response) in enumerate(zip(predicted_control_srs, control_srs, control_responses)): 

1610 fig, ax = plt.subplots(1, 2, figsize=(8, 3)) 

1611 ax[0].set_title('Control Signal {:}'.format(i)) 

1612 ax[0].plot(times, response) 

1613 ax[0].set_label('Acceleration') 

1614 ax[0].set_xlabel('Time (s)') 

1615 ax[1].set_title('Control SRS {:}'.format(i)) 

1616 ax[1].plot(sine_frequencies, control, 'k--') 

1617 ax[1].plot(sine_frequencies, predicted) 

1618 ax[1].set_ylabel('SRS ({:0.2f}% damping)'.format(srs_damping*100)) 

1619 ax[1].set_xlabel('Frequency (Hz)') 

1620 ax[1].set_yscale('log') 

1621 ax[1].set_xscale('log') 

1622 ax[1].legend(('Desired', 'Achieved')) 

1623 fig.tight_layout() 

1624 return_vals += (fig, ax) 

1625 # Now plot all limit channels 

1626 if limit_srs is not None: 

1627 for i, (predicted, limit, response) in enumerate(zip(predicted_limit_srs, limit_srs, limit_responses)): 

1628 fig, ax = plt.subplots(1, 2, figsize=(8, 3)) 

1629 ax[0].set_title('Limit Signal {:}'.format(i)) 

1630 ax[0].plot(times, response) 

1631 ax[0].set_label('Acceleration') 

1632 ax[0].set_xlabel('Time (s)') 

1633 ax[1].set_title('Limit SRS {:}'.format(i)) 

1634 ax[1].plot(sine_frequencies, limit, 'k--') 

1635 ax[1].plot(sine_frequencies, predicted) 

1636 ax[1].set_ylabel('SRS ({:0.2f}% damping)'.format(srs_damping*100)) 

1637 ax[1].set_xlabel('Frequency (Hz)') 

1638 ax[1].set_yscale('log') 

1639 ax[1].set_xscale('log') 

1640 ax[1].legend(('Desired', 'Achieved')) 

1641 fig.tight_layout() 

1642 return_vals += (fig, ax) 

1643 

1644 return return_vals 

1645 

1646 

1647@dataclass 

1648class WindowedRandomMetrics: 

1649 """ 

1650 Scalar metrics describing one generated realization. 

1651 

1652 Parameters 

1653 ---------- 

1654 lanl_rms_db_error : float 

1655 LANL-style root-mean-square dB error between the achieved and target 

1656 SRS over the matching frequencies. 

1657 energy : float 

1658 Signal energy computed from the acceleration time history. 

1659 rea : float 

1660 Root energy amplitude. 

1661 peak_accel : float 

1662 Maximum absolute acceleration in the compensated windowed history. 

1663 peak_vel : float 

1664 Maximum absolute velocity derived by integrating the compensated 

1665 acceleration history. 

1666 peak_disp : float 

1667 Maximum absolute displacement derived by integrating the compensated 

1668 acceleration history. 

1669 

1670 Notes 

1671 ----- 

1672 These metrics are derived from the compensated windowed realization and are 

1673 intended for ranking or selecting among multiple random realizations. 

1674 """ 

1675 lanl_rms_db_error: float 

1676 energy: float 

1677 rea: float 

1678 peak_accel: float 

1679 peak_vel: float 

1680 peak_disp: float 

1681 

1682 

1683@dataclass 

1684class WindowedRandomResults: 

1685 """ 

1686 Collection of generated windowed-random realizations and associated results. 

1687 

1688 Parameters 

1689 ---------- 

1690 time : ndarray 

1691 Time vector with shape `(n_samples,)`. 

1692 sample_rate : float 

1693 Sampling rate in samples per second. 

1694 gravity : float 

1695 Acceleration-of-gravity conversion factor used when integrating 

1696 acceleration to obtain velocity and displacement. 

1697 window : ndarray 

1698 Applied time-domain window with shape `(n_samples,)`. 

1699 ref_srs : ndarray 

1700 Reference SRS breakpoint array with shape `(n_breakpoints, 2)`. The 

1701 first column contains frequency and the second contains spectral 

1702 amplitude. 

1703 srs_frequency : ndarray 

1704 Frequency vector corresponding to `srs_matrix`, with shape `(n_freq,)`. 

1705 srs_matrix : ndarray 

1706 Computed SRS values for each realization, with shape 

1707 `(n_freq, n_realizations)`. 

1708 xc_matrix : ndarray 

1709 Compensated windowed acceleration histories, with shape 

1710 `(n_samples, n_realizations)`. 

1711 rr_matrix : ndarray 

1712 Stationary random acceleration histories prior to windowing, with shape 

1713 `(n_samples, n_realizations)`. 

1714 metrics : list of WindowedRandomMetrics 

1715 Metrics for each realization. 

1716 metadata : dict 

1717 Dictionary of generation settings and other bookkeeping information. 

1718 

1719 Notes 

1720 ----- 

1721 Each column of `xc_matrix`, `rr_matrix`, and `srs_matrix` corresponds to 

1722 one realization. 

1723 """ 

1724 time: np.ndarray 

1725 sample_rate: float 

1726 gravity: float 

1727 window: np.ndarray 

1728 ref_srs: np.ndarray 

1729 srs_frequency: np.ndarray 

1730 srs_matrix: np.ndarray 

1731 xc_matrix: np.ndarray 

1732 rr_matrix: np.ndarray 

1733 metrics: List[WindowedRandomMetrics] 

1734 metadata: Dict[str, Any] 

1735 

1736 @property 

1737 def metrics_array(self) -> np.ndarray: 

1738 """ 

1739 Return the realization metrics as a dense numeric array. 

1740 

1741 Returns 

1742 ------- 

1743 ndarray 

1744 Array with shape `(n_realizations, 6)` containing columns: 

1745 

1746 1. LANL RMS dB error 

1747 2. energy 

1748 3. REA 

1749 4. peak acceleration 

1750 5. peak velocity 

1751 6. peak displacement 

1752 """ 

1753 return np.array([ 

1754 [ 

1755 m.lanl_rms_db_error, 

1756 m.energy, 

1757 m.rea, 

1758 m.peak_accel, 

1759 m.peak_vel, 

1760 m.peak_disp, 

1761 ] 

1762 for m in self.metrics 

1763 ], dtype=float) 

1764 

1765 

1766@dataclass 

1767class SelectedRealization: 

1768 """ 

1769 One realization selected from an ensemble. 

1770 

1771 Parameters 

1772 ---------- 

1773 index : int 

1774 Zero-based index of the selected realization. 

1775 time : ndarray 

1776 Time vector with shape `(n_samples,)`. 

1777 xc : ndarray 

1778 Compensated windowed acceleration history with shape `(n_samples,)`. 

1779 rr : ndarray 

1780 Stationary random acceleration history with shape `(n_samples,)`. 

1781 srs : ndarray 

1782 Shock response spectrum of the selected realization with shape 

1783 `(n_freq, 2)`, where the first column is frequency and the second is 

1784 SRS amplitude. 

1785 metrics : WindowedRandomMetrics 

1786 Scalar metrics associated with the selected realization. 

1787 """ 

1788 index: int 

1789 time: np.ndarray 

1790 xc: np.ndarray 

1791 rr: np.ndarray 

1792 srs: np.ndarray 

1793 metrics: WindowedRandomMetrics 

1794 

1795 

1796def breakpoint_spectrum( 

1797 frequencies: np.ndarray, 

1798 break_frequencies: np.ndarray, 

1799 break_values: np.ndarray, 

1800 floor: Optional[float] = None, 

1801) -> np.ndarray: 

1802 """ 

1803 Evaluate a breakpoint-defined spectrum using log-log interpolation. 

1804 

1805 Parameters 

1806 ---------- 

1807 frequencies : ndarray 

1808 Frequencies at which the spectrum should be evaluated. 

1809 break_frequencies : ndarray 

1810 Monotonically increasing breakpoint frequencies. 

1811 break_values : ndarray 

1812 Spectrum amplitudes at `break_frequencies`. 

1813 floor : float, optional 

1814 Value used outside the breakpoint range. If not provided, the default 

1815 is `min(break_values) / 1000`. 

1816 

1817 Returns 

1818 ------- 

1819 ndarray 

1820 Spectrum amplitudes evaluated at `frequencies`. 

1821 

1822 Raises 

1823 ------ 

1824 ValueError 

1825 If any breakpoint frequencies or breakpoint amplitudes are not 

1826 positive. 

1827 

1828 Notes 

1829 ----- 

1830 This function reproduces the numerical intent of MATLAB ``bpspec``: 

1831 straight lines on log-log axes between breakpoint pairs, with a constant 

1832 floor outside the breakpoint range. 

1833 """ 

1834 frequencies = np.asarray(frequencies, dtype=float) 

1835 break_frequencies = np.asarray(break_frequencies, dtype=float) 

1836 break_values = np.asarray(break_values, dtype=float) 

1837 

1838 if np.any(break_frequencies <= 0): 

1839 raise ValueError("break_frequencies must be positive") 

1840 if np.any(break_values <= 0): 

1841 raise ValueError("break_values must be positive") 

1842 

1843 if floor is None: 

1844 floor = np.min(break_values) / 1000.0 

1845 

1846 y = np.full_like(frequencies, floor, dtype=float) 

1847 inside = (frequencies >= break_frequencies[0]) & (frequencies <= break_frequencies[-1]) 

1848 

1849 if np.any(inside): 

1850 f = interp1d( 

1851 np.log(break_frequencies), 

1852 np.log(break_values), 

1853 kind="linear", 

1854 bounds_error=False, 

1855 fill_value=np.log(floor), 

1856 ) 

1857 y[inside] = np.exp(f(np.log(frequencies[inside]))) 

1858 

1859 return y 

1860 

1861 

1862def temporal_moments(x: np.ndarray, dt: float) -> Dict[str, float]: 

1863 """ 

1864 Compute temporal moments of a sampled signal. 

1865 

1866 Parameters 

1867 ---------- 

1868 x : ndarray 

1869 Input signal amplitudes with shape `(n_samples,)`. 

1870 dt : float 

1871 Sampling interval. 

1872 

1873 Returns 

1874 ------- 

1875 dict 

1876 Dictionary containing: 

1877 

1878 - ``"energy"`` : float 

1879 Signal energy. 

1880 - ``"tau"`` : float 

1881 Time centroid. 

1882 - ``"duration_rms"`` : float 

1883 Root-mean-square duration. 

1884 - ``"skewness_metric"`` : float 

1885 Cube-root form of the third central temporal moment. 

1886 - ``"kurtosis_metric"`` : float 

1887 Fourth-root form of the fourth central temporal moment. 

1888 - ``"rea"`` : float 

1889 Root energy amplitude. 

1890 

1891 Notes 

1892 ----- 

1893 This function follows the intent of MATLAB ``gtempmom`` and is used to 

1894 compute metrics for each generated acceleration history. 

1895 """ 

1896 x = np.asarray(x, dtype=float).reshape(-1) 

1897 t = dt * np.arange(len(x)) 

1898 z = x ** 2 

1899 

1900 E = abs(dt * np.sum(z)) 

1901 tau = float(dt * np.dot(t, z) / E) 

1902 

1903 t_centered = t - tau 

1904 D2 = float((dt / E) * np.dot(t_centered ** 2, z)) 

1905 D = np.sqrt(max(D2, 0.0)) 

1906 

1907 S3 = float((dt / E) * np.dot(t_centered ** 3, z)) 

1908 K4 = float((dt / E) * np.dot(t_centered ** 4, z)) 

1909 

1910 S = np.sign(S3) * abs(S3) ** (1 / 3) if S3 != 0 else 0.0 

1911 K = abs(K4) ** 0.25 

1912 R = np.sqrt(E / D) if D > 0 else np.inf 

1913 

1914 return { 

1915 "energy": E, 

1916 "tau": tau, 

1917 "duration_rms": D, 

1918 "skewness_metric": S, 

1919 "kurtosis_metric": K, 

1920 "rea": R, 

1921 } 

1922 

1923 

1924def lanl_rms_db_error( 

1925 db_error: np.ndarray, 

1926 frequency: np.ndarray, 

1927 octave_resolution: float, 

1928) -> Dict[str, np.ndarray | float]: 

1929 """ 

1930 Compute LANL-style RMS dB error over octave-spaced frequency lines. 

1931 

1932 Parameters 

1933 ---------- 

1934 db_error : ndarray 

1935 dB error values. May be one-dimensional with shape `(n_freq,)` or 

1936 two-dimensional with shape `(n_freq, n_series)`. 

1937 frequency : ndarray 

1938 Frequency vector with shape `(n_freq,)`. 

1939 octave_resolution : float 

1940 Number of points per octave. 

1941 

1942 Returns 

1943 ------- 

1944 dict 

1945 Dictionary containing: 

1946 

1947 - ``"rms"`` : float 

1948 Average RMS dB error across series. 

1949 - ``"std"`` : float 

1950 Average standard deviation of dB error across series. 

1951 - ``"avg"`` : ndarray 

1952 Mean dB error across series at each frequency. 

1953 

1954 Raises 

1955 ------ 

1956 ValueError 

1957 If the first dimension of `db_error` does not match the length of 

1958 `frequency`. 

1959 

1960 Notes 

1961 ----- 

1962 This follows the octave-band weighting used in the MATLAB 

1963 ``lanl_rmsdberror`` routine. 

1964 """ 

1965 db_error = np.asarray(db_error, dtype=float) 

1966 frequency = np.asarray(frequency, dtype=float).reshape(-1) 

1967 

1968 if db_error.ndim == 1: 

1969 db_error = db_error[:, None] 

1970 

1971 if db_error.shape[0] != len(frequency): 

1972 raise ValueError("frequency length must match first dimension of db_error") 

1973 

1974 upper = (2 ** (1 / (2 * octave_resolution))) * frequency 

1975 lower = np.concatenate([[frequency[0] / (2 ** (1 / (2 * octave_resolution)))], upper[:-1]]) 

1976 band = upper - lower 

1977 frange = upper[-1] - lower[0] 

1978 

1979 avg = np.mean(db_error, axis=1) 

1980 

1981 rms_vals = [] 

1982 std_vals = [] 

1983 for j in range(db_error.shape[1]): 

1984 rms_vals.append(np.sqrt((1.0 / frange) * np.sum((db_error[:, j] ** 2) * band))) 

1985 std_vals.append(np.sqrt((1.0 / frange) * np.sum(((db_error[:, j] - avg) ** 2) * band))) 

1986 

1987 return { 

1988 "rms": float(np.mean(rms_vals)), 

1989 "std": float(np.mean(std_vals)), 

1990 "avg": avg, 

1991 } 

1992 

1993 

1994_NUTTALL_COEFFS = { 

1995 0: (1.0, 0.0, 0.0, 0.0), 

1996 1: (0.5, 0.5, 0.0, 0.0), 

1997 2: (0.42, 0.50, 0.08, 0.0), 

1998 3: (7938 / 18608, 9240 / 18608, 1430 / 18608, 0.0), 

1999 4: (0.42323, 0.49755, 0.07922, 0.0), 

2000 5: (0.44959, 0.49364, 0.05677, 0.0), 

2001 6: (0.35875, 0.48829, 0.14128, 0.01168), 

2002 7: (0.40217, 0.49703, 0.09892, 0.00188), 

2003 8: (0.375, 0.5, 0.125, 0.0), 

2004 9: (0.40897, 0.5, 0.09103, 0.0), 

2005 10: (10 / 32, 15 / 32, 6 / 32, 1 / 32), 

2006 11: (0.338936, 0.481973, 0.161054, 0.018027), 

2007 12: (0.355768, 0.487396, 0.144232, 0.012604), 

2008 13: (0.53836, 0.46164, 0.0, 0.0), 

2009 14: (0.4243801, 0.4973406, 0.0782793, 0.0), 

2010 15: (0.3635819, 0.4891775, 0.1365995, 0.0106411), 

2011} 

2012 

2013 

2014def nuttall_window(n: int, window_type: int = 12) -> np.ndarray: 

2015 """ 

2016 Generate a Nuttall-family window. 

2017 

2018 Parameters 

2019 ---------- 

2020 n : int 

2021 Number of samples in the window. 

2022 window_type : int, default=12 

2023 Window type identifier. Only the subset required by the compensation 

2024 algorithm is supported here. 

2025 

2026 Returns 

2027 ------- 

2028 ndarray 

2029 Window values with shape `(n,)`. 

2030 

2031 Raises 

2032 ------ 

2033 ValueError 

2034 If `n` is not positive or if `window_type` is unsupported. 

2035 

2036 Notes 

2037 ----- 

2038 This is a minimal implementation derived from the MATLAB ``nuttall`` 

2039 routine and is included only to support waveform compensation. 

2040 """ 

2041 if n <= 0: 

2042 raise ValueError("n must be positive") 

2043 

2044 if window_type == 16: 

2045 return windows.flattop(n, sym=False) 

2046 if window_type == 17: 

2047 if n % 2 == 0: 

2048 t = np.arange(-n // 2, n // 2) 

2049 else: 

2050 m = (n - 1) / 2 

2051 t = np.arange(-m - 0.5, m + 0.5, 1.0) 

2052 return np.cos(np.pi * t / n) 

2053 if window_type == 19: 

2054 return windows.kaiser(n, beta=9.0, sym=False) 

2055 

2056 if window_type not in _NUTTALL_COEFFS: 

2057 raise ValueError(f"Unsupported Nuttall window type {window_type}") 

2058 

2059 if n % 2 == 0: 

2060 t = np.arange(-n // 2, n // 2) 

2061 else: 

2062 m = (n - 1) / 2 

2063 t = np.arange(-m - 0.5, m + 0.5, 1.0) 

2064 

2065 a0, a1, a2, a3 = _NUTTALL_COEFFS[window_type] 

2066 return ( 

2067 a0 

2068 + a1 * np.cos(2 * np.pi * t / n) 

2069 + a2 * np.cos(4 * np.pi * t / n) 

2070 + a3 * np.cos(6 * np.pi * t / n) 

2071 ) 

2072 

2073 

2074def window_compensation( 

2075 x: np.ndarray, 

2076 window: np.ndarray | float, 

2077 sample_rate: float, 

2078 delay: float = 0.0, 

2079) -> Dict[str, np.ndarray]: 

2080 """ 

2081 Apply a compensation waveform to enforce zero terminal cumulative moments. 

2082 

2083 Parameters 

2084 ---------- 

2085 x : ndarray 

2086 Input waveform with shape `(n_samples,)`. 

2087 window : ndarray or float 

2088 If an array, it is used directly as the compensation window. If a 

2089 scalar, it is interpreted as a compensation frequency and a Nuttall 

2090 window is generated internally. 

2091 sample_rate : float 

2092 Sampling rate in samples per second. 

2093 delay : float, default=0.0 

2094 Delay applied to the compensating waveform, in seconds. 

2095 

2096 Returns 

2097 ------- 

2098 dict 

2099 Dictionary containing: 

2100 

2101 - ``"comp"`` : ndarray 

2102 The compensating waveform. 

2103 - ``"xc"`` : ndarray 

2104 The compensated waveform. 

2105 - ``"coefficients"`` : ndarray 

2106 The three coefficients multiplying the DC, sine, and cosine 

2107 compensation basis terms. 

2108 

2109 Notes 

2110 ----- 

2111 This function follows the numerical approach of MATLAB ``sdwcomp``. The 

2112 compensation is constructed from three basis terms: a windowed DC term, 

2113 a windowed sine term, and a windowed cosine term. Their amplitudes are 

2114 chosen so that the final cumulative sums corresponding to acceleration, 

2115 velocity, and displacement-like integrals vanish. 

2116 """ 

2117 x = np.asarray(x, dtype=float).reshape(-1) 

2118 

2119 if np.max(np.abs(x)) <= np.finfo(float).eps: 

2120 return { 

2121 "comp": np.zeros_like(x), 

2122 "xc": np.zeros_like(x), 

2123 "coefficients": np.zeros(3), 

2124 } 

2125 

2126 if np.isscalar(window): 

2127 freq = float(window) 

2128 lc = int(np.fix(sample_rate / freq)) 

2129 w = nuttall_window(lc, window_type=12) 

2130 else: 

2131 w = np.asarray(window, dtype=float).reshape(-1) 

2132 lc = len(w) 

2133 freq = sample_rate / lc 

2134 

2135 t = np.arange(lc) / sample_rate 

2136 acd = w 

2137 acs = w * np.sin(2 * np.pi * freq * t) 

2138 acc = w * np.cos(2 * np.pi * freq * t) 

2139 

2140 ld = int(np.fix(delay * sample_rate)) 

2141 lx = len(x) 

2142 

2143 if ld < 0: 

2144 if lc + ld <= lx: 

2145 l = -ld + lx 

2146 xw = np.concatenate([np.zeros(l - lx), x]) 

2147 acd = np.concatenate([acd, np.zeros(l - lc)]) 

2148 acs = np.concatenate([acs, np.zeros(l - lc)]) 

2149 acc = np.concatenate([acc, np.zeros(l - lc)]) 

2150 else: 

2151 l = lc 

2152 xw = np.concatenate([np.zeros(-ld), x, np.zeros(l - lx + ld)]) 

2153 else: 

2154 if ld + lc <= lx: 

2155 l = lx 

2156 xw = x.copy() 

2157 acd = np.concatenate([np.zeros(ld), acd, np.zeros(lx - ld - lc)]) 

2158 acs = np.concatenate([np.zeros(ld), acs, np.zeros(lx - ld - lc)]) 

2159 acc = np.concatenate([np.zeros(ld), acc, np.zeros(lx - ld - lc)]) 

2160 else: 

2161 l = ld + lc 

2162 xw = np.concatenate([x, np.zeros(l - lx)]) 

2163 acd = np.concatenate([np.zeros(ld), acd]) 

2164 acs = np.concatenate([np.zeros(ld), acs]) 

2165 acc = np.concatenate([np.zeros(ld), acc]) 

2166 

2167 xv = np.cumsum(xw) 

2168 xd = np.cumsum(xv) 

2169 xe = np.cumsum(xd) 

2170 

2171 vcd = np.cumsum(acd) 

2172 vcs = np.cumsum(acs) 

2173 vcc = np.cumsum(acc) 

2174 

2175 dcd = np.cumsum(vcd) 

2176 dcs = np.cumsum(vcs) 

2177 dcc = np.cumsum(vcc) 

2178 

2179 ecd = np.cumsum(dcd) 

2180 ecs = np.cumsum(dcs) 

2181 ecc = np.cumsum(dcc) 

2182 

2183 rhs = -np.array([xv[-1], xd[-1], xe[-1]], dtype=float) 

2184 mat = np.array([ 

2185 [vcd[-1], vcs[-1], vcc[-1]], 

2186 [dcd[-1], dcs[-1], dcc[-1]], 

2187 [ecd[-1], ecs[-1], ecc[-1]], 

2188 ], dtype=float) 

2189 

2190 coeff = np.linalg.solve(mat, rhs) 

2191 comp = coeff[0] * acd + coeff[1] * acs + coeff[2] * acc 

2192 xc = xw + comp 

2193 

2194 return { 

2195 "comp": comp, 

2196 "xc": xc, 

2197 "coefficients": coeff, 

2198 } 

2199 

2200 

2201def _inverse_real_spectrum(one_sided_spectrum: np.ndarray) -> np.ndarray: 

2202 """ 

2203 Reconstruct a real time history from a one-sided complex spectrum. 

2204 

2205 Parameters 

2206 ---------- 

2207 one_sided_spectrum : ndarray 

2208 One-sided complex spectrum of length `floor(N/2) + 1`. 

2209 

2210 Returns 

2211 ------- 

2212 ndarray 

2213 Real-valued time history reconstructed by inverse FFT. 

2214 

2215 Notes 

2216 ----- 

2217 This matches the convention used by MATLAB ``rffti``: the omitted 

2218 negative-frequency half of the spectrum is inferred by Hermitian 

2219 symmetry. 

2220 """ 

2221 X = np.asarray(one_sided_spectrum, dtype=complex).reshape(-1) 

2222 n = len(X) 

2223 

2224 if np.abs(np.imag(X[-1])) != 0: 

2225 Y = np.concatenate([X, np.conj(X[n - 1:0:-1])]) 

2226 else: 

2227 Y = np.concatenate([X, np.conj(X[n - 2:0:-1])]) 

2228 

2229 return np.fft.ifft(Y).real 

2230 

2231 

2232def generate_single_windowed_random_realization( 

2233 ref_srs: np.ndarray, 

2234 window: np.ndarray, 

2235 sample_rate: float, 

2236 gravity: float = 386.0, 

2237 damping: float = 0.03, 

2238 srs_type: int = 9, 

2239 match_points_per_octave: float = 4.0, 

2240 output_points_per_octave: float = 12.0, 

2241 amp_init: Optional[np.ndarray] = None, 

2242 phase_init: Optional[np.ndarray] = None, 

2243 iterations: int = 10, 

2244 correction: float = 0.8, 

2245 error_tolerance: float = 0.05, 

2246 randomize_phase: bool = False, 

2247 max_frequency: Optional[float] = None, 

2248 rng: Optional[np.random.Generator] = None, 

2249) -> Dict[str, Any]: 

2250 """ 

2251 Generate a single windowed random realization matched to a target SRS. 

2252 

2253 Parameters 

2254 ---------- 

2255 ref_srs : ndarray 

2256 Target SRS breakpoint array with shape `(n_breakpoints, 2)`. The first 

2257 column is frequency and the second is SRS amplitude. 

2258 window : ndarray 

2259 Time-domain window with shape `(n_samples,)`. 

2260 sample_rate : float 

2261 Sampling rate in samples per second. 

2262 gravity : float, default=386.0 

2263 Acceleration-of-gravity conversion factor used when integrating to 

2264 velocity and displacement. 

2265 damping : float, default=0.03 

2266 Fraction of critical damping used for SRS calculations. 

2267 srs_type : int, default=9 

2268 SRS type passed to :func:`sdynpy_srs.srs`. 

2269 match_points_per_octave : float, default=4.0 

2270 Frequency resolution used during the iterative SRS matching process. 

2271 output_points_per_octave : float, default=12.0 

2272 Frequency resolution used for the returned SRS curve. 

2273 amp_init : ndarray, optional 

2274 Initial spectrum amplitude guess as a two-column array 

2275 `[frequency, amplitude]`. 

2276 phase_init : ndarray, optional 

2277 Initial one-sided phase vector in radians. 

2278 iterations : int, default=10 

2279 Maximum number of matching iterations. 

2280 correction : float, default=0.8 

2281 Fraction of the SRS error corrected at each iteration. 

2282 error_tolerance : float, default=0.05 

2283 Relative SRS error tolerance for convergence. 

2284 randomize_phase : bool, default=False 

2285 If True, ignore `phase_init` and generate a random phase realization. 

2286 max_frequency : float, optional 

2287 Maximum frequency for nonzero Fourier amplitude. If not specified, the 

2288 default is `min(sample_rate/4, ref_srs[-1, 0])`. 

2289 rng : numpy.random.Generator, optional 

2290 Random number generator used when random phase is required. 

2291 

2292 Returns 

2293 ------- 

2294 dict 

2295 Dictionary containing: 

2296 

2297 - ``"time"`` : ndarray 

2298 Time vector. 

2299 - ``"xc"`` : ndarray 

2300 Compensated windowed acceleration history. 

2301 - ``"rr"`` : ndarray 

2302 Stationary random acceleration history before windowing. 

2303 - ``"srs_freq"`` : ndarray 

2304 Frequency vector for the returned SRS. 

2305 - ``"srs_values"`` : ndarray 

2306 SRS values for the returned realization. 

2307 - ``"srsc"`` : ndarray 

2308 Matching-frequency SRS array with columns `[frequency, amplitude]`. 

2309 - ``"metrics"`` : WindowedRandomMetrics 

2310 Scalar metrics for the realization. 

2311 - ``"amp"`` : ndarray 

2312 Final matched spectral amplitudes at the matching frequencies. 

2313 - ``"phase"`` : ndarray 

2314 Final phase vector used for waveform synthesis. 

2315 

2316 Raises 

2317 ------ 

2318 ValueError 

2319 If the FFT line spacing is too coarse to resolve the lowest matched SRS 

2320 frequency, or if the internally defined frequency grid is invalid. 

2321 

2322 Notes 

2323 ----- 

2324 This function is the numerical core corresponding to one pass through the 

2325 MATLAB ``wrcomp_tdh`` / ``gwinrand`` workflow. 

2326 """ 

2327 ref_srs = np.asarray(ref_srs, dtype=float) 

2328 window = np.asarray(window, dtype=float).reshape(-1) 

2329 rng = np.random.default_rng() if rng is None else rng 

2330 

2331 n = len(window) 

2332 t = np.arange(n) / sample_rate 

2333 

2334 fmin = ref_srs[0, 0] 

2335 fmax = ref_srs[-1, 0] 

2336 

2337 if max_frequency is None: 

2338 max_frequency = min(sample_rate / 4.0, fmax) 

2339 

2340 freq_match = octspace(fmin, fmax, match_points_per_octave).reshape(-1) 

2341 

2342 if phase_init is None: 

2343 block_size = int(2 ** np.ceil(np.log2(n))) 

2344 else: 

2345 block_size = 2 * (len(phase_init) - 1) 

2346 

2347 delta_f = sample_rate / block_size 

2348 if delta_f > freq_match[0]: 

2349 raise ValueError( 

2350 "FFT line spacing is too coarse to match the lowest SRS frequency. " 

2351 f"delta_f={delta_f}, fmin={freq_match[0]}" 

2352 ) 

2353 

2354 freq_line = np.arange(block_size // 2 + 1) * delta_f 

2355 if len(freq_line) < 2: 

2356 raise ValueError("Frequency line vector too short") 

2357 freq_line[0] = freq_line[1] / 10.0 

2358 

2359 if amp_init is not None and len(amp_init) > 0: 

2360 amp_init = np.asarray(amp_init, dtype=float) 

2361 amp_match = breakpoint_spectrum(freq_match, amp_init[:, 0], amp_init[:, 1]) 

2362 else: 

2363 amp_match = None 

2364 

2365 if amp_match is None: 

2366 amp_line = 2 * np.pi * (1 / delta_f) * breakpoint_spectrum( 

2367 freq_line, ref_srs[:, 0], ref_srs[:, 1] 

2368 ) 

2369 amp_line[0] = 0.0 

2370 amp_match = np.interp(freq_match, freq_line, amp_line, left=0.0, right=0.0) / freq_match 

2371 else: 

2372 amp_line = breakpoint_spectrum(freq_line, freq_match, amp_match) 

2373 

2374 if randomize_phase or phase_init is None or len(phase_init) == 0: 

2375 phase = 2 * np.pi * rng.random(len(amp_line)) 

2376 phase[0] = 0.0 

2377 phase[-1] = 0.0 

2378 else: 

2379 phase = np.asarray(phase_init, dtype=float).reshape(-1) 

2380 

2381 srs_required = breakpoint_spectrum(freq_match, ref_srs[:, 0], ref_srs[:, 1]) 

2382 

2383 spectrum = amp_line * np.exp(1j * phase) 

2384 rr_full = _inverse_real_spectrum(spectrum) 

2385 rr = rr_full[:n] 

2386 xr = window * rr 

2387 

2388 xc = window_compensation(xr, window, sample_rate, delay=0.0)["xc"][:n] 

2389 

2390 srsc_values = srs(xc, 1.0 / sample_rate, freq_match, damping, srs_type)[0] 

2391 srsc = np.column_stack([freq_match, srsc_values]) 

2392 

2393 maxline = np.where(freq_line > max_frequency)[0] 

2394 amp_iter = amp_match.copy() 

2395 

2396 for _ in range(1, iterations): 

2397 amp_match = amp_iter + amp_iter * correction * (srs_required - srsc[:, 1]) / srsc[:, 1] 

2398 amp_match = np.maximum(0.0, amp_match) 

2399 amp_iter = amp_match.copy() 

2400 

2401 f1, f2 = freq_match[0], freq_match[1] 

2402 f3, f4 = freq_match[-2], freq_match[-1] 

2403 freqq = np.concatenate([ 

2404 [0.0], 

2405 [f1 + f1 / 2 - f2 / 2], 

2406 freq_match, 

2407 [f4 + f4 - f3], 

2408 [sample_rate / 2.0], 

2409 ]) 

2410 ampp = np.concatenate([ 

2411 [amp_match[0] / 1000.0], 

2412 [amp_match[0]], 

2413 amp_match, 

2414 [amp_match[-1]], 

2415 [amp_match[-1] / 1000.0], 

2416 ]) 

2417 

2418 amp_line = np.interp(freq_line, freqq, ampp, left=0.0, right=0.0) 

2419 amp_line[maxline] = 0.0 

2420 

2421 nz = np.abs(amp_line[np.abs(amp_line) > 0]) 

2422 if len(nz): 

2423 amp_line = np.maximum(0.01 * np.min(nz), amp_line) 

2424 

2425 spectrum = amp_line * np.exp(1j * phase) 

2426 rr_full = _inverse_real_spectrum(spectrum) 

2427 rr = rr_full[:n] 

2428 xr = window * rr 

2429 

2430 xc = window_compensation(xr, window, sample_rate, delay=0.0)["xc"][:n] 

2431 srsc[:, 1] = srs(xc, 1.0 / sample_rate, freq_match, damping, srs_type)[0] 

2432 

2433 srs_error = (srsc[:, 1] - srs_required) / srs_required 

2434 if np.max(np.abs(srs_error)) < error_tolerance: 

2435 break 

2436 

2437 srs_freq = octspace(fmin, fmax, output_points_per_octave) 

2438 srs_values = srs(xc, 1.0 / sample_rate, srs_freq, damping, srs_type)[0] 

2439 

2440 vc = (gravity / sample_rate) * cumulative_trapezoid(xc, initial=0.0) 

2441 dc = (1.0 / sample_rate) * cumulative_trapezoid(vc, initial=0.0) 

2442 

2443 sref_interp = breakpoint_spectrum(srsc[:, 0], ref_srs[:, 0], ref_srs[:, 1]) 

2444 db_error = 20.0 * np.log10(srsc[:, 1] / sref_interp) 

2445 err = lanl_rms_db_error(db_error, srsc[:, 0], output_points_per_octave) 

2446 tm = temporal_moments(xc, 1.0 / sample_rate) 

2447 

2448 metrics = WindowedRandomMetrics( 

2449 lanl_rms_db_error=err["rms"], 

2450 energy=tm["energy"], 

2451 rea=tm["rea"], 

2452 peak_accel=float(np.max(np.abs(xc))), 

2453 peak_vel=float(np.max(np.abs(vc))), 

2454 peak_disp=float(np.max(np.abs(dc))), 

2455 ) 

2456 

2457 return { 

2458 "time": t, 

2459 "xc": xc, 

2460 "rr": rr, 

2461 "srs_freq": srs_freq, 

2462 "srs_values": srs_values, 

2463 "srsc": srsc, 

2464 "metrics": metrics, 

2465 "amp": amp_match, 

2466 "phase": phase, 

2467 } 

2468 

2469 

2470def generate_windowed_random( 

2471 ref_srs: np.ndarray, 

2472 window: np.ndarray, 

2473 sample_rate: float, 

2474 gravity: float = 386.0, 

2475 damping: float = 0.03, 

2476 srs_type: int = 9, 

2477 match_points_per_octave: float = 4.0, 

2478 output_points_per_octave: float = 12.0, 

2479 n_realizations: int = 1, 

2480 amp_init: Optional[np.ndarray] = None, 

2481 phase_init: Optional[np.ndarray] = None, 

2482 iterations: int = 10, 

2483 correction: float = 0.8, 

2484 error_tolerance: float = 0.05, 

2485 randomize_phase: bool = False, 

2486 max_frequency: Optional[float] = None, 

2487 seed: Optional[int] = None, 

2488) -> WindowedRandomResults: 

2489 """ 

2490 Generate an ensemble of windowed random time histories matched to a target SRS. 

2491 

2492 Parameters 

2493 ---------- 

2494 ref_srs : ndarray 

2495 Target SRS breakpoint array with shape `(n_breakpoints, 2)`. The first 

2496 column is frequency and the second is amplitude. 

2497 window : ndarray 

2498 Time-domain window with shape `(n_samples,)`. 

2499 sample_rate : float 

2500 Sampling rate in samples per second. 

2501 gravity : float, default=386.0 

2502 Acceleration-of-gravity conversion factor used when integrating to 

2503 velocity and displacement. 

2504 damping : float, default=0.03 

2505 Fraction of critical damping used for SRS calculations. 

2506 srs_type : int, default=9 

2507 SRS type passed to :func:`sdynpy_srs.srs`. 

2508 match_points_per_octave : float, default=4.0 

2509 Frequency resolution used for SRS matching during iteration. 

2510 output_points_per_octave : float, default=12.0 

2511 Frequency resolution used for the returned SRS curves. 

2512 n_realizations : int, default=1 

2513 Number of random realizations to generate. 

2514 amp_init : ndarray, optional 

2515 Initial spectrum amplitude guess as a two-column array 

2516 `[frequency, amplitude]`. 

2517 phase_init : ndarray, optional 

2518 Initial one-sided phase vector in radians. 

2519 iterations : int, default=10 

2520 Maximum number of matching iterations per realization. 

2521 correction : float, default=0.8 

2522 Fraction of the SRS error corrected at each iteration. 

2523 error_tolerance : float, default=0.05 

2524 Relative SRS error tolerance for convergence. 

2525 randomize_phase : bool, default=False 

2526 If True, ignore `phase_init` and randomize the phase realization. 

2527 max_frequency : float, optional 

2528 Maximum frequency for nonzero Fourier amplitude. If not specified, the 

2529 default is `min(sample_rate/4, ref_srs[-1, 0])`. 

2530 seed : int, optional 

2531 Seed for the random number generator. 

2532 

2533 Returns 

2534 ------- 

2535 WindowedRandomResults 

2536 Ensemble result object containing all realizations, their SRS curves, 

2537 and selection metrics. 

2538 

2539 Notes 

2540 ----- 

2541 Each realization is generated independently using the same configuration 

2542 except for the random phase realization when randomization is enabled. 

2543 """ 

2544 ref_srs = np.asarray(ref_srs, dtype=float) 

2545 window = np.asarray(window, dtype=float).reshape(-1) 

2546 

2547 rng = np.random.default_rng(seed) 

2548 t = np.arange(len(window)) / sample_rate 

2549 

2550 xc_list = [] 

2551 rr_list = [] 

2552 srs_list = [] 

2553 metrics = [] 

2554 srs_freq = None 

2555 

2556 for _ in range(n_realizations): 

2557 out = generate_single_windowed_random_realization( 

2558 ref_srs=ref_srs, 

2559 window=window, 

2560 sample_rate=sample_rate, 

2561 gravity=gravity, 

2562 damping=damping, 

2563 srs_type=srs_type, 

2564 match_points_per_octave=match_points_per_octave, 

2565 output_points_per_octave=output_points_per_octave, 

2566 amp_init=amp_init, 

2567 phase_init=phase_init, 

2568 iterations=iterations, 

2569 correction=correction, 

2570 error_tolerance=error_tolerance, 

2571 randomize_phase=randomize_phase, 

2572 max_frequency=max_frequency, 

2573 rng=rng, 

2574 ) 

2575 

2576 xc_list.append(out["xc"]) 

2577 rr_list.append(out["rr"]) 

2578 srs_list.append(out["srs_values"]) 

2579 metrics.append(out["metrics"]) 

2580 

2581 if srs_freq is None: 

2582 srs_freq = out["srs_freq"] 

2583 

2584 return WindowedRandomResults( 

2585 time=t, 

2586 sample_rate=sample_rate, 

2587 gravity=gravity, 

2588 window=window, 

2589 ref_srs=ref_srs, 

2590 srs_frequency=srs_freq, 

2591 srs_matrix=np.column_stack(srs_list), 

2592 xc_matrix=np.column_stack(xc_list), 

2593 rr_matrix=np.column_stack(rr_list), 

2594 metrics=metrics, 

2595 metadata={ 

2596 "damping": damping, 

2597 "srs_type": srs_type, 

2598 "match_points_per_octave": match_points_per_octave, 

2599 "output_points_per_octave": output_points_per_octave, 

2600 "iterations": iterations, 

2601 "correction": correction, 

2602 "error_tolerance": error_tolerance, 

2603 "randomize_phase": randomize_phase, 

2604 "max_frequency": max_frequency, 

2605 "n_realizations": n_realizations, 

2606 }, 

2607 ) 

2608 

2609 

2610def select_realization( 

2611 results: WindowedRandomResults, 

2612 criterion: str = "lanl_rms_db_error", 

2613 index: Optional[int] = None, 

2614) -> SelectedRealization: 

2615 """ 

2616 Select a realization from an ensemble. 

2617 

2618 Parameters 

2619 ---------- 

2620 results : WindowedRandomResults 

2621 Ensemble results returned by :func:`generate_windowed_random`. 

2622 criterion : str, default="lanl_rms_db_error" 

2623 Name of the metric field to minimize if `index` is not provided. 

2624 index : int, optional 

2625 Explicit zero-based realization index to select. If provided, this 

2626 takes precedence over `criterion`. 

2627 

2628 Returns 

2629 ------- 

2630 SelectedRealization 

2631 Selected realization containing the time history, SRS, and scalar 

2632 metrics. 

2633 

2634 Raises 

2635 ------ 

2636 AttributeError 

2637 If `criterion` is not a valid attribute of 

2638 :class:`WindowedRandomMetrics`. 

2639 

2640 Notes 

2641 ----- 

2642 This function provides the deterministic equivalent of the MATLAB 

2643 ``gwinrand_select`` helper without interactive point-picking. 

2644 """ 

2645 if index is None: 

2646 values = np.array([getattr(m, criterion) for m in results.metrics], dtype=float) 

2647 index = int(np.argmin(values)) 

2648 

2649 return SelectedRealization( 

2650 index=index, 

2651 time=results.time, 

2652 xc=results.xc_matrix[:, index], 

2653 rr=results.rr_matrix[:, index], 

2654 srs=np.column_stack([results.srs_frequency, results.srs_matrix[:, index]]), 

2655 metrics=results.metrics[index], 

2656 )