Coverage for src/sdynpy/core/sdynpy_system.py: 39%

710 statements  

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

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

2""" 

3Defines a system of matrices representing a structure. 

4 

5The System consists of mass, stiffness, and (if necessary) damping matrices. 

6The System also contains a CoordinateArray defining the degrees of freedom of 

7the System, as well as a transformation that takes the System from its internal 

8state degrees of freedom to physical degrees of freedom. 

9""" 

10""" 

11Copyright 2022 National Technology & Engineering Solutions of Sandia, 

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

13Government retains certain rights in this software. 

14 

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

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

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

18(at your option) any later version. 

19 

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

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

22MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23GNU General Public License for more details. 

24 

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

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

27""" 

28 

29import numpy as np 

30from .sdynpy_coordinate import CoordinateArray, from_nodelist, outer_product, coordinate_array 

31from ..fem.sdynpy_beam import beamkm, rect_beam_props 

32from ..fem.sdynpy_shaker import Shaker4DoF 

33from ..signal_processing import frf as spfrf 

34from ..signal_processing import generator 

35from scipy.linalg import eigh, block_diag, null_space, eig 

36from scipy.signal import lsim, StateSpace, resample, butter, filtfilt 

37import copy 

38import netCDF4 as nc4 

39import matplotlib.pyplot as plt 

40import warnings 

41from pathlib import Path 

42 

43 

44class System: 

45 """Matrix Equations representing a Structural Dynamics System""" 

46 

47 def __init__(self, coordinate: CoordinateArray, mass, stiffness, damping=None, 

48 transformation=None, enforce_symmetry = True): 

49 """ 

50 Create a system representation including mass, stiffness, damping, and 

51 transformation matrices. 

52 

53 Parameters 

54 ---------- 

55 coordinate : CoordinateArray 

56 Physical degrees of freedom in the system. 

57 mass : np.ndarray 

58 2D array consisting of the mass matrix of the system 

59 stiffness : np.ndarray 

60 2D array consisting of the stiffness matrix of the system 

61 damping : np.ndarray, optional 

62 2D array consisting of the damping matrix of the system. If not 

63 specified, the damping will be zero. 

64 transformation : np.ndarray, optional 

65 A transformation between internal "state" degrees of freedom and 

66 the physical degrees of freedom defined in `coordinate`. The 

67 default transformation is the identity matrix. 

68 enforce_symmetry : bool, optional 

69 If True, raise a ValueError if the matrices provided are not 

70 symmetric. 

71 

72 Raises 

73 ------ 

74 ValueError 

75 If inputs are improperly sized 

76 

77 Returns 

78 ------- 

79 None. 

80 

81 """ 

82 mass = np.atleast_2d(np.array(mass)) 

83 stiffness = np.atleast_2d(np.array(stiffness)) 

84 if not mass.shape == stiffness.shape: 

85 raise ValueError('Mass and Stiffness matrices must be the same shape') 

86 if not mass.ndim == 2 or (mass.shape[0] != mass.shape[1]): 

87 raise ValueError('Mass should be a 2D, square array') 

88 if not stiffness.ndim == 2 or (stiffness.shape[0] != stiffness.shape[1]): 

89 raise ValueError('Stiffness should be a 2D, square array') 

90 if damping is None: 

91 damping = np.zeros(stiffness.shape) 

92 else: 

93 damping = np.atleast_2d(np.array(damping)) 

94 if not damping.shape == stiffness.shape: 

95 raise ValueError('Damping and Stiffness matrices must be the same shape') 

96 if not damping.ndim == 2 or (damping.shape[0] != damping.shape[1]): 

97 raise ValueError('Damping should be a 2D, square array') 

98 if transformation is None: 

99 transformation = np.eye(mass.shape[0]) 

100 else: 

101 transformation = np.atleast_2d(np.array(transformation)) 

102 if not transformation.ndim == 2: 

103 raise ValueError('transformation must be 2D') 

104 if not transformation.shape[-1] == mass.shape[0]: 

105 raise ValueError( 

106 'transformation must have number of columns equal to the number of rows in the mass matrix') 

107 coordinate = np.atleast_1d(coordinate) 

108 if not isinstance(coordinate, CoordinateArray): 

109 raise ValueError('coordinate must be a CoordinateArray object') 

110 if not coordinate.ndim == 1 or coordinate.shape[0] != transformation.shape[0]: 

111 raise ValueError( 

112 'coordinate must be 1D and have the same size as transformation.shape[0] or mass.shape[0] if no transformation is specified') 

113 # Check symmetry 

114 if not np.allclose(mass, mass.T) and enforce_symmetry: 

115 raise ValueError('mass matrix must be symmetric') 

116 if not np.allclose(stiffness, stiffness.T, atol=1e-6*stiffness.max()) and enforce_symmetry: 

117 raise ValueError('stiffness matrix must be symmetric') 

118 if not np.allclose(damping, damping.T) and enforce_symmetry: 

119 raise ValueError('damping matrix must be symmetric') 

120 

121 self._coordinate = coordinate 

122 self._mass = mass 

123 self._stiffness = stiffness 

124 self._damping = damping 

125 self._transformation = transformation 

126 self._enforce_symmetry = enforce_symmetry 

127 

128 def __repr__(self): 

129 return 'System with {:} DoFs ({:} internal DoFs)'.format(self.ndof_transformed, self.ndof) 

130 

131 def spy(self, subplots_kwargs={'figsize': (10, 3)}, spy_kwargs={}): 

132 """ 

133 Plot the structure of the system's matrices 

134 

135 Parameters 

136 ---------- 

137 subplots_kwargs : dict, optional 

138 Default arguments passed to `matplotlib.pyplot`'s `subplots` function. 

139 The default is {'figsize':(10,3)}. 

140 spy_kwargs : dict, optional 

141 Default arguments passed to `matplotlib.pyplot`'s 'spy' function. 

142 The default is {}. 

143 

144 Returns 

145 ------- 

146 ax : Axes 

147 Axes on which the subplots are defined. 

148 

149 """ 

150 fig, ax = plt.subplots(1, 3, squeeze=True, **subplots_kwargs) 

151 ax[0].spy(abs(self.transformation), **spy_kwargs) 

152 ax[1].spy(abs(self.mass) + abs(self.stiffness) + abs(self.damping), **spy_kwargs) 

153 ax[2].spy(abs(self.transformation.T), **spy_kwargs) 

154 ax[0].set_title('Output Transformation') 

155 ax[1].set_title('Internal State Matrices') 

156 ax[2].set_title('Input Transformation') 

157 fig.tight_layout() 

158 # Trying to figure out how to scale the subfigures identically... 

159 # plt.pause(0.01) 

160 # # Now adjust the sizes of the plots 

161 # sizes = [] 

162 # for a in ax: 

163 # bbox = a.get_window_extent().transformed(fig.dpi_scale_trans.inverted()) 

164 # width, height = bbox.width, bbox.height 

165 # sizes.append((width, height)) 

166 # scales = [sizes[0][0],sizes[1][1],sizes[2][1]] 

167 # plt.pause(0.01) 

168 # scales = [scale/min(scales) for scale in scales] 

169 # for a,scale in zip(ax,scales): 

170 # position = a.get_position() 

171 # center_x = (position.xmax + position.xmin)/2 

172 # center_y = (position.ymax + position.ymin)/2 

173 # new_left = center_x - (center_x - position.xmin)/2 

174 # new_bottom = center_y - (center_y - position.ymin)/2 

175 # a.set_position([new_left,new_bottom,position.width/scale,position.height/scale]) 

176 return ax 

177 

178 @property 

179 def transformation(self): 

180 """Get or set the transformation matrix""" 

181 return self._transformation 

182 

183 @transformation.setter 

184 def transformation(self, value): 

185 """Set the transformation matrix""" 

186 if not value.ndim == 2: 

187 raise ValueError('transformation must be 2D') 

188 if not value.shape[-1] == self.mass.shape[0]: 

189 raise ValueError( 

190 'transformation must have number of columns equal to the number of rows in the mass matrix') 

191 self._transformation = value 

192 

193 @property 

194 def coordinate(self): 

195 """Get or set the degrees of freedom in the system""" 

196 return self._coordinate 

197 

198 @coordinate.setter 

199 def coordinate(self, value): 

200 """Set the degrees of freedom in the system""" 

201 if not isinstance(value, CoordinateArray): 

202 raise ValueError('coordinate must be a CoordinateArray object') 

203 if not value.ndim == 1 or value.shape[0] != self.mass.shape[0]: 

204 raise ValueError('coordinate must be 1D and have the same size as mass.shape[0]') 

205 self._coordinate = value 

206 

207 @property 

208 def mass(self): 

209 """Get or set the mass matrix of the system""" 

210 return self._mass 

211 

212 @mass.setter 

213 def mass(self, value): 

214 """Set the mass matrix of the system""" 

215 if not value.shape == self.stiffness.shape: 

216 raise ValueError('Mass and Stiffness matrices must be the same shape') 

217 if not value.ndim == 2 or (value.shape[0] != value.shape[1]): 

218 raise ValueError('Mass should be a 2D, square array') 

219 if not np.allclose(value, value.T) and self._enforce_symmetry: 

220 raise ValueError('mass matrix must be symmetric') 

221 self._mass = value 

222 

223 @property 

224 def stiffness(self): 

225 """Get or set the stffness matrix of the system""" 

226 return self._stiffness 

227 

228 @stiffness.setter 

229 def stiffness(self, value): 

230 """Set the stiffness matrix of the system""" 

231 if not value.shape == self.mass.shape: 

232 raise ValueError('Mass and Stiffness matrices must be the same shape') 

233 if not value.ndim == 2 or (value.shape[0] != value.shape[1]): 

234 raise ValueError('Stiffness should be a 2D, square array') 

235 if not np.allclose(value, value.T) and self._enforce_symmetry: 

236 raise ValueError('stiffness matrix must be symmetric') 

237 self._stiffness = value 

238 

239 @property 

240 def damping(self): 

241 """Get or set the damping matrix of the system""" 

242 return self._damping 

243 

244 @damping.setter 

245 def damping(self, value): 

246 """Set the damping matrix of the system""" 

247 if not value.shape == self.stiffness.shape: 

248 raise ValueError('damping and Stiffness matrices must be the same shape') 

249 if not value.ndim == 2 or (value.shape[0] != value.shape[1]): 

250 raise ValueError('damping should be a 2D, square array') 

251 if not np.allclose(value, value.T) and self._enforce_symmetry: 

252 raise ValueError('damping matrix must be symmetric') 

253 self._damping = value 

254 

255 M = mass 

256 K = stiffness 

257 C = damping 

258 

259 @property 

260 def ndof(self): 

261 """Get the number of internal degrees of freedom of the system""" 

262 return self.mass.shape[0] 

263 

264 @property 

265 def ndof_transformed(self): 

266 """Get the number of physical degrees of freedom of the system""" 

267 return self.transformation.shape[0] 

268 

269 @property 

270 def massless_dofs(self): 

271 """Gets the massless degrees of freedom in the system""" 

272 return np.all(self.mass == 0, axis=0) 

273 

274 @property 

275 def massive_dofs(self): 

276 return ~self.massless_dofs 

277 

278 def to_state_space(self, response_coordinates = None, excitation_coordinates = None, 

279 output_excitation_signals = True, extra_outputs = None): 

280 """ 

281 Creates state space matrices A, B, C, and D from a SDynPy System object 

282 

283 Parameters 

284 ---------- 

285 response_coordinates : dict, optional 

286 A dictionary with keys 0, 1, and 2 representing the displacement 

287 derivative and CoordinateArray values representing the degrees of 

288 freedom desired at that degree of freedom. For example, a 

289 dictionary {0:sdpy.coordinate_array([101,102],['X+','X+']), 

290 2:sdpy.coordinate_array([203,204],['X+','Y+'])} would give 

291 displacement results at 101X+ and 102X+ and acceleration results at 

292 203X+ and 204Y+. The value of None can also be supplied to get all 

293 degrees of freedom at that derivative. For example, {2:None} would 

294 get acceleration results at all degrees of freedom in the System. 

295 If not specified, the output will be given for displacement, 

296 velocity, and acceleration, for all degrees of freedom. Rows of 

297 the output vector will be ordered first by derivative order then 

298 by degree of freedom order, so all displacements come before all 

299 velocities, and those before all accelerations. 

300 excitation_coordinates : CoordinateArray, optional 

301 The degrees of freedom to use as inputs in the state and output 

302 equations. The default is to provide inputs at all degrees of freedom. 

303 output_excitation_signals : bool, optional 

304 If True, the inputs to the system will be passed through and output 

305 as part of the output matrices C and D. The default is True. These 

306 will be in the last rows of C and D, one for each of the excitation 

307 degrees of freedom. 

308 extra_outputs : dict 

309 A dictionary containing data to allow additional outputs to be 

310 obtained from the state space formulation. The dictionary must 

311 have keys 'C' and 'D'. These must contain NumPy ndarray objects that 

312 will be concatenated with the C and D matrices. Both matrices 

313 should have number of rows equal to the number of extra outputs 

314 requested. The number of columns of C should be equal to the 

315 number of states (2x number of massive degrees of freedom + 1x 

316 number of massless degrees of freedom). States are ordered 

317 displacements of massive 

318 degrees of freedom, velocities of massive degrees of freedom, 

319 displacements of massless degrees of freedom. Within each group, 

320 they will be ordered in the same order as the system matrices (not 

321 the transformation matrix). The number of columns of the D matrix 

322 should be equivalent to the number of excitation coordinates 

323 provided. 

324 

325 Returns 

326 ------- 

327 A : ndarray 

328 The state matrix. States are ordered displacements of massive 

329 degrees of freedom, velocities of massive degrees of freedom, 

330 displacements of massless degrees of freedom. Within each group, 

331 they will be ordered in the same order as the system matrices (not 

332 the transformation matrix). 

333 B : ndarray 

334 The input matrix. Inputs are in the same order as the 

335 excitation_coordinate argument. States are in the order as 

336 described for the A return value. 

337 C : ndarray 

338 The output matrix. States are in the order as described for the A 

339 return value. Outputs are ordered as follows: displacement 

340 responses, velocity responses, acceleration responses, 

341 excitation signals, extra outputs. Within each group, 

342 the coordinates are ordered the same way as the provided 

343 response_coordinate and excitation_coordinate arguments. 

344 D : ndarray 

345 The feedforward matrix. Input order is described in the return 

346 value for B. Output order is described in the return value for C. 

347 """ 

348 if response_coordinates is None: 

349 response_coordinates = {0:self.coordinate, 1:self.coordinate, 2:self.coordinate} 

350 phi_response = {} 

351 for derivative in [0,1,2]: 

352 if derivative in response_coordinates: 

353 if isinstance(response_coordinates[derivative], str) and response_coordinates[derivative] == 'state': 

354 # If you want the state variables back, the transformation is the 

355 # identity matrix. 

356 phi_response[derivative] = np.eye(self.transformation.shape[-1]) 

357 elif response_coordinates[derivative] is None: 

358 phi_response[derivative] = self.transformation 

359 else: 

360 phi_response[derivative] = self.transformation_matrix_at_coordinates(response_coordinates[derivative]) 

361 else: 

362 phi_response[derivative] = self.transformation_matrix_at_coordinates(CoordinateArray(0)) 

363 if excitation_coordinates is None: 

364 phi_input = self.transformation 

365 else: 

366 phi_input = self.transformation_matrix_at_coordinates(excitation_coordinates) 

367 tdofs_input = phi_input.shape[0] 

368 

369 massless_dofs = np.all(self.mass == 0, axis=0) 

370 ix_11 = np.ix_(~massless_dofs,~massless_dofs) 

371 ix_12 = np.ix_(~massless_dofs,massless_dofs) 

372 ix_22 = np.ix_(massless_dofs,massless_dofs) 

373 ix_21 = np.ix_(massless_dofs,~massless_dofs) 

374 n2 = np.sum(massless_dofs) 

375 n1 = np.sum(~massless_dofs) 

376 M_11 = self.mass[ix_11] 

377 K_11 = self.stiffness[ix_11] 

378 K_22 = self.stiffness[ix_22] 

379 K_12 = self.stiffness[ix_12] 

380 K_21 = self.stiffness[ix_21] 

381 C_11 = self.damping[ix_11] 

382 C_22 = self.damping[ix_22] 

383 C_12 = self.damping[ix_12] 

384 C_21 = self.damping[ix_21] 

385 Z_11 = np.zeros((n1,n1)) 

386 Z_12 = np.zeros((n1,n2)) 

387 Z_1i = np.zeros((n1,tdofs_input)) 

388 Z_2i = np.zeros((n2,tdofs_input)) 

389 I_1 = np.eye(n1) 

390 I_i = np.eye(tdofs_input) 

391 Phi_i1 = phi_input[:,~massless_dofs] 

392 Phi_i2 = phi_input[:,massless_dofs] 

393 

394 tdofs_response = {} 

395 Phi_r1 = {} 

396 Phi_r2 = {} 

397 Z_r1 = {} 

398 Z_r2 = {} 

399 Z_ri = {} 

400 for derivative in [0,1,2]: 

401 tdofs_response[derivative] = phi_response[derivative].shape[0] 

402 Phi_r1[derivative] = phi_response[derivative][:,~massless_dofs] 

403 Phi_r2[derivative] = phi_response[derivative][:,massless_dofs] 

404 Z_r1[derivative] = np.zeros((tdofs_response[derivative],n1)) 

405 Z_r2[derivative] = np.zeros((tdofs_response[derivative],n2)) 

406 Z_ri[derivative] = np.zeros((tdofs_response[derivative],tdofs_input)) 

407 

408 if np.any(Phi_r2[2] != 0): 

409 warnings.warn('It looks like you are asking for the second order derivative of a degree of freedom that only has a first-order derivative defined. Acceleration of massless degrees of freedom cannot be computed and will be set to zero.') 

410 

411 # Degree of freedom ordering = [x1,v1,x2] (Disp 1, Vel 1, Disp 2) 

412 # Input ordering [V] (Voltages) 

413 

414 # # x1, v1, x2 

415 A = np.block([[Z_11,I_1, Z_12], 

416 [np.linalg.solve(M_11,C_12)@np.linalg.solve(C_22,K_21) - np.linalg.solve(M_11,K_11), 

417 -np.linalg.solve(M_11,C_11) + np.linalg.solve(M_11,C_12)@np.linalg.solve(C_22,C_21), 

418 np.linalg.solve(M_11,C_12)@np.linalg.solve(C_22,K_22) - np.linalg.solve(M_11,K_12)], 

419 [-np.linalg.solve(C_22,K_21), -np.linalg.solve(C_22,C_21), -np.linalg.solve(C_22,K_22)]]) 

420 

421 B = np.block([[Z_1i], 

422 [np.linalg.solve(M_11,Phi_i1.T)+np.linalg.solve(M_11,C_12)@np.linalg.solve(C_22,Phi_i2.T)], 

423 [np.linalg.solve(C_22,Phi_i2.T)]]) 

424 

425 # Contributions from the massive degrees of freedom 

426 C1 = np.block([ 

427 # For displacements, just the states x1 

428 [Phi_r1[0], Z_r1[0], Z_r2[0]], 

429 # For velocities, just the states v1 

430 [Z_r1[1], Phi_r1[1], Z_r2[1]], 

431 # For acceleration, we want the row of A corresponding to v1dot 

432 [Phi_r1[2] @ np.block([[np.linalg.solve(M_11,C_12)@np.linalg.solve(C_22,K_21) - np.linalg.solve(M_11,K_11), 

433 -np.linalg.solve(M_11,C_11) + np.linalg.solve(M_11,C_12)@np.linalg.solve(C_22,C_21), 

434 np.linalg.solve(M_11,C_12)@np.linalg.solve(C_22,K_22) - np.linalg.solve(M_11,K_12)]])], 

435 # For the inputs, we pass through the inputs 

436 [Z_1i.T,Z_1i.T,Z_2i.T]]) 

437 

438 # Contributions from the massless degrees of freedom 

439 C2 = np.block([ 

440 # For displacements, just the states x2 

441 [Z_r1[0], Z_r1[0], Phi_r2[0]], 

442 # For the velocities, we want the row of A corresponding to x2dot 

443 [Phi_r2[1] @ np.block([[-np.linalg.solve(C_22,K_21), -np.linalg.solve(C_22,C_21), -np.linalg.solve(C_22,K_22)]])], 

444 # For the accelerations, we will have zeros 

445 [Z_r1[2], Z_r1[2], Z_r2[2]], 

446 # For the inputs, we will pass through the inputs 

447 [Z_1i.T,Z_1i.T,Z_2i.T]]) 

448 

449 C = C1+C2 

450 

451 D = np.block([ 

452 # For the displacements, we will have zeros 

453 [Z_ri[0]], 

454 # For the velocities, we want the row of B corresponding to x2dot 

455 [Phi_r2[1] @ np.linalg.solve(C_22,Phi_i2.T)], 

456 # For the accelerations, we want the row of B corresponding to v1dot 

457 [Phi_r1[2] @ (np.linalg.solve(M_11,Phi_i1.T)+np.linalg.solve(M_11,C_12)@np.linalg.solve(C_22,Phi_i2.T))], 

458 # For the inputs, we will pass through the inputs 

459 [I_i] 

460 ]) 

461 

462 if not output_excitation_signals: 

463 C = C[:-tdofs_input] 

464 D = D[:-tdofs_input] 

465 

466 if extra_outputs is not None: 

467 C = np.concatenate((C,extra_outputs['C'])) 

468 D = np.concatenate((D,extra_outputs['D'])) 

469 

470 return A,B,C,D 

471 

472 def time_integrate(self, forces, responses=None, 

473 initial_state=None, 

474 integration_oversample=1, extra_outputs = None): 

475 """ 

476 Integrate a system to produce responses to an excitation 

477 

478 Parameters 

479 ---------- 

480 forces : TimeHistoryArray 

481 The forces applied to the system, which should be a 

482 `TimeHistoryArray`. The time step and excitation degrees of freedom 

483 will be taken from the `TimeHistoryArray` 

484 responses : dict, optional 

485 A dictionary with keys 0, 1, and 2 representing the displacement 

486 derivative and CoordinateArray values representing the degrees of 

487 freedom desired at that degree of freedom. For example, a 

488 dictionary {0:sdpy.coordinate_array([101,102],['X+','X+']), 

489 2:sdpy.coordinate_array([203,204],['X+','Y+'])} would give 

490 displacement results at 101X+ and 102X+ and acceleration results at 

491 203X+ and 204Y+. The value of None can also be supplied to get all 

492 degrees of freedom at that derivative. For example, {2:None} would 

493 get acceleration results at all degrees of freedom in the System. 

494 If not specified, the output will be given for displacement, 

495 velocity, and acceleration, for all degrees of freedom. Rows of 

496 the output vector will be ordered first by derivative order then 

497 by degree of freedom order, so all displacements come before all 

498 velocities, and those before all accelerations. 

499 initial_state : np.ndarray, optional 

500 The initial conditions of the integration. The default is zero 

501 displacement and zero velocity. 

502 integration_oversample : int 

503 The amount of oversampling that will be applied to the force by 

504 zero-padding the fft. It is generally better to create the forces 

505 such that they are oversampled than to use this argument. 

506 extra_outputs : dict 

507 A dictionary containing data to allow additional outputs to be 

508 obtained from the state space formulation. The dictionary must 

509 have keys 'C', 'D', and 'coordinate'. 

510 Values for 'C' and 'D' must contain NumPy ndarray objects that 

511 will be concatenated with the C and D matrices. See the 

512 documentation for `to_state_space` for more information on these 

513 keys. Additionally, the values of the 'coordinate' key must 

514 contain a CoordinateArray object with the same size as the number 

515 of rows of C and D. These will be assigned as the coordinates of 

516 the extra signals in the outputted TimeHistoryArray. 

517 

518 Returns 

519 ------- 

520 response_array : TimeHistoryArray 

521 The responses of the system to the forces applied 

522 

523 """ 

524 from .sdynpy_data import data_array, FunctionTypes 

525 dt = forces.abscissa_spacing 

526 references = forces.coordinate[..., 0] 

527 forces = forces.ordinate 

528 references = np.atleast_1d(references) 

529 A, B, C, D = self.to_state_space(responses, references, False, 

530 extra_outputs=extra_outputs) 

531 forces = np.atleast_2d(forces) 

532 times = np.arange(forces.shape[-1]) * dt 

533 linear_system = StateSpace(A, B, C, D) 

534 if integration_oversample != 1: 

535 forces, times = resample(forces, len(times) * integration_oversample, times, axis=-1) 

536 times_out, time_response, x_out = lsim(linear_system, forces.T, times, initial_state) 

537 if time_response.ndim == 1: 

538 time_response = time_response[:, np.newaxis] 

539 response_coordinates = [] 

540 if responses is None: 

541 responses = {0:self.coordinate, 1:self.coordinate, 2:self.coordinate} 

542 for derivative in [0,1,2]: 

543 if derivative in responses: 

544 if isinstance(responses[derivative], str) and responses[derivative] == 'state': 

545 # If you want the state variables back, the transformation is the 

546 # identity matrix. 

547 response_coordinates.append(coordinate_array(np.arange(self.ndof)+1,0)) 

548 elif responses[derivative] is None: 

549 response_coordinates.append(self.coordinate) 

550 else: 

551 response_coordinates.append(responses[derivative]) 

552 if extra_outputs is not None: 

553 response_coordinates.append(extra_outputs['coordinate']) 

554 response_coordinates = np.concatenate(response_coordinates) 

555 response_array = data_array(FunctionTypes.TIME_RESPONSE, 

556 times, time_response.T, 

557 response_coordinates[:, np.newaxis]) 

558 if integration_oversample != 1: 

559 response_array = response_array.extract_elements( 

560 slice(None, None, integration_oversample)) 

561 return response_array 

562 

563 def eigensolution(self, num_modes=None, maximum_frequency=None, complex_modes=False, return_shape=True): 

564 """ 

565 Computes the eigensolution of the system 

566 

567 Parameters 

568 ---------- 

569 num_modes : int, optional 

570 The number of modes of the system to compute. The default is to 

571 compute all the modes. 

572 maximum_frequency : float, optional 

573 The maximum frequency to which modes will be computed. 

574 The default is to compute all the modes. 

575 complex_modes : bool, optional 

576 Whether or not complex modes are computed. The default is False. 

577 return_shape : bool, optional 

578 Specifies whether or not to return a `ShapeArray` (True) or a reduced 

579 `System` (False). The default is True. 

580 

581 Raises 

582 ------ 

583 NotImplementedError 

584 Raised if complex modes are specified. 

585 

586 Returns 

587 ------- 

588 System or ShapeArray 

589 If `return_shape` is True, the a ShapeArray will be returned. If 

590 `return_shape` is False, a reduced system will be returned. 

591 

592 """ 

593 if complex_modes is False: 

594 if num_modes is not None: 

595 num_modes = [0, int(num_modes) - 1] 

596 if maximum_frequency is not None: 

597 maximum_frequency = (2 * np.pi * maximum_frequency)**2 

598 maximum_frequency = [-maximum_frequency, maximum_frequency] # Convert to eigenvalue 

599 lam, phi = eigh(self.K, self.M, subset_by_index=num_modes, 

600 subset_by_value=maximum_frequency) 

601 # Mass normalize the mode shapes 

602 lam[lam < 0] = 0 

603 freq = np.sqrt(lam) / (2 * np.pi) 

604 normalized_mass = np.diag(phi.T @ self.M @ phi) 

605 phi /= np.sqrt(normalized_mass) 

606 # Ignore divide by zero if the frequency is zero 

607 with np.errstate(divide='ignore', invalid='ignore'): 

608 damping = np.diag(phi.T @ self.C @ phi) / (2 * (2 * np.pi * freq)) 

609 damping[np.isnan(damping)] = 0.0 

610 damping[np.isinf(damping)] = 0.0 

611 # Add in the transformation to get back to physical dofs 

612 phi = self.transformation @ phi 

613 if return_shape: 

614 from .sdynpy_shape import shape_array 

615 return shape_array(self.coordinate, phi.T, freq, damping) 

616 else: 

617 return System(self.coordinate, np.eye(freq.size), np.diag((2 * np.pi * freq)**2), np.diag(2 * (2 * np.pi * freq) * damping), phi) 

618 else: 

619 if self.ndof > 1000: 

620 warnings.warn('The complex mode implementation currently computes all eigenvalues and eigenvectors, which may take a long time for large systems.') 

621 # For convenience, assign a zeros matrix 

622 Z = np.zeros(self.M.shape) 

623 A = np.block([[ Z, self.M], 

624 [self.M, self.C]]) 

625 B = np.block([[-self.M, Z], 

626 [ Z, self.K]]) 

627 lam, E = eig(-B,A) 

628 # Sort the eigenvalues such that they are increasing in frequency 

629 isort = np.argsort(np.abs(lam)) 

630 lam = lam[isort] 

631 E = E[:,isort] 

632 # Eigenvalues will be in complex conjugate pairs. Let's only keep 

633 # the ones that are greater than zero. 

634 keep = lam.imag > 0 

635 lam = lam[keep] 

636 E = E[:,keep] 

637 if A.shape[0]//2 != lam.size: 

638 warnings.warn('The complex mode implementation currently does not do well with rigid body modes (0 Hz frequencies). Compute them from a real-modes solution then transform to complex.') 

639 # Cull values we don't want 

640 if num_modes is not None: 

641 lam = lam[:num_modes] 

642 E = E[:,:num_modes] 

643 if maximum_frequency is not None: 

644 keep = np.abs(lam) <= maximum_frequency*2*np.pi 

645 lam = lam[keep] 

646 E = E[:,keep] 

647 # Mass normalize the mode shapes 

648 E = E/np.sqrt(np.einsum('ji,jk,ki->i',E,A,E)) 

649 # Find repeated eigenvalues where the eigenvectors are not orthogonal 

650 # TODO: Might have to do some orthogonalization for repeated 

651 # eigenvalues 

652 # A_modal = np.einsum('ji,jk,kl->il',E,A,E) 

653 # Extract just the displacement partition 

654 psi = E[E.shape[0]//2:,:] 

655 # Add in a transformation to get to physical dofs 

656 psi_t = self.transformation @ psi 

657 frequency = np.abs(lam)/(2*np.pi) 

658 damping = -np.real(lam)/np.abs(lam) 

659 if return_shape: 

660 from .sdynpy_shape import shape_array 

661 return shape_array(self.coordinate, psi.T, frequency, damping) 

662 else: 

663 warnings.warn('Complex Modes will in general not diagonalize the system M, C, and K, matrices.') 

664 return System(self.coordinate, 

665 psi.T@self.M@psi, 

666 psi.T@self.K@psi.T, 

667 psi.T@self.C@psi.T, 

668 psi_t) 

669 

670 def transformation_shapes(self, shape_indices=None): 

671 from .sdynpy_shape import shape_array 

672 if shape_indices is None: 

673 shape_indices = slice(None) 

674 shape_matrix = self.transformation[:, shape_indices] 

675 return shape_array(self.coordinate, shape_matrix.T, 

676 frequency=0, damping=0) 

677 

678 def remove_transformation(self): 

679 return System(coordinate_array(np.arange(self.ndof)+1,0), 

680 self.mass.copy(),self.stiffness.copy(),self.damping.copy()) 

681 

682 def frequency_response(self, frequencies, responses=None, references=None, 

683 displacement_derivative=0): 

684 """ 

685 Computes frequency response functions at the specified frequency lines. 

686 

687 Parameters 

688 ---------- 

689 frequencies : ndarray 

690 A 1D array of frequencies. 

691 responses : CoordinateArray, optional 

692 A set of coordinates to compute responses. The default is to 

693 create responses at all coordinates. 

694 references : CoordinateArray, optional 

695 A set of coordinates to use as inputs. The default is to use all 

696 coordinates as inputs. 

697 displacement_derivative : int, optional 

698 The number of derivatives to apply to the response. The default is 

699 0, which corresponds to displacement. 1 would be the first 

700 derivative, velocity, and 2 would be the second derivative, 

701 acceleration. 

702 

703 Returns 

704 ------- 

705 frf : TransferFunctionArray 

706 A TransferFunctionArray containing the frequency response function 

707 for the system at the specified input and output degrees of freedom. 

708 

709 """ 

710 H = spfrf.sysmat2frf(frequencies, self.M, self.C, self.K) 

711 H = (1j * (2 * np.pi * frequencies[:, np.newaxis, np.newaxis]))**displacement_derivative * H 

712 # Apply transformations 

713 if responses is None: 

714 output_transform = self.transformation 

715 output_coordinates = self.coordinate 

716 else: 

717 responses = np.atleast_1d(responses) 

718 output_transform = self.transformation_matrix_at_coordinates(responses) 

719 output_coordinates = responses 

720 if references is None: 

721 input_transform = self.transformation 

722 input_coordinates = self.coordinate 

723 else: 

724 references = np.atleast_1d(references) 

725 input_transform = self.transformation_matrix_at_coordinates(references) 

726 input_coordinates = references 

727 H = output_transform @ H @ input_transform.T 

728 # Put it into a transfer function array 

729 from .sdynpy_data import data_array, FunctionTypes 

730 frf = data_array(FunctionTypes.FREQUENCY_RESPONSE_FUNCTION, frequencies, 

731 np.moveaxis(H, 0, -1), 

732 outer_product(output_coordinates, input_coordinates)) 

733 return frf 

734 

735 def assign_modal_damping(self, damping_ratios): 

736 """ 

737 Assigns a damping matrix to the system that results in equivalent 

738 modal damping 

739 

740 Parameters 

741 ---------- 

742 damping_ratios : ndarray 

743 An array of damping values to assign to the system 

744 

745 Returns 

746 ------- 

747 None. 

748 

749 """ 

750 damping_ratios = np.array(damping_ratios) 

751 if damping_ratios.ndim == 1: 

752 shapes = self.eigensolution(num_modes=damping_ratios.size) 

753 else: 

754 shapes = self.eigensolution() 

755 shapes.damping = damping_ratios 

756 # Compute the damping matrix 

757 modal_system = shapes.system() 

758 shape_pinv = np.linalg.pinv(modal_system.transformation.T) 

759 full_damping_matrix = shape_pinv@modal_system.damping@shape_pinv.T 

760 self.damping[:] = full_damping_matrix 

761 

762 def save(self, filename): 

763 """ 

764 Saves the system to a file 

765 

766 Parameters 

767 ---------- 

768 filename : str 

769 Name of the file in which the system will be saved. 

770 

771 Returns 

772 ------- 

773 None. 

774 

775 """ 

776 np.savez(filename, mass=self.mass, stiffness=self.stiffness, damping=self.damping, 

777 transformation=self.transformation, coordinate=self.coordinate.view(np.ndarray), 

778 enforce_symmetry = self._enforce_symmetry) 

779 

780 @classmethod 

781 def load(cls, filename): 

782 """ 

783 Load a system from a file 

784 

785 Parameters 

786 ---------- 

787 filename : str 

788 Name of the file from which the system will be loaded. 

789 

790 Returns 

791 ------- 

792 System 

793 A system consisting of the mass, stiffness, damping, and transformation 

794 in the file 

795 

796 """ 

797 if isinstance(filename, Path): 

798 filename = str(filename) 

799 data = np.load(filename) 

800 return cls(data['coordinate'].view(CoordinateArray), data['mass'], 

801 data['stiffness'], data['damping'], data['transformation'], 

802 True if 'enforce_symmetry' not in data else data['enforce_symmetry']) 

803 

804 def __neg__(self): 

805 new_system = copy.deepcopy(self) 

806 new_system.mass *= -1 

807 new_system.stiffness *= -1 

808 new_system.damping *= -1 

809 return new_system 

810 

811 def __mul__(self, value): 

812 new_system = copy.deepcopy(self) 

813 new_system.mass *= value 

814 new_system.stiffness *= value 

815 new_system.damping *= value 

816 return new_system 

817 

818 def __rmul__(self, value): 

819 new_system = copy.deepcopy(self) 

820 new_system.mass *= value 

821 new_system.stiffness *= value 

822 new_system.damping *= value 

823 return new_system 

824 

825 @classmethod 

826 def concatenate(cls, systems, coordinate_node_offset=0): 

827 """ 

828 Combine multiple systems together 

829 

830 Parameters 

831 ---------- 

832 systems : iterable of System objects 

833 Iterable of Systems that will be concatenated. Matrices will be 

834 assembled in block diagonal format 

835 coordinate_node_offset : int, optional 

836 Offset applied to the coordinates so the nodes do not overlap. 

837 The default is 0. 

838 

839 Returns 

840 ------- 

841 System 

842 A system consisting of the combintation of the provided systems. 

843 

844 """ 

845 coordinates = [system.coordinate.copy() for system in systems] 

846 if coordinate_node_offset != 0: 

847 for i in range(len(coordinates)): 

848 coordinates[i].node += coordinate_node_offset * (i + 1) 

849 all_coordinates = np.concatenate(coordinates) 

850 return cls(all_coordinates, 

851 block_diag(*[system.mass for system in systems]), 

852 block_diag(*[system.stiffness for system in systems]), 

853 block_diag(*[system.damping for system in systems]), 

854 block_diag(*[system.transformation for system in systems]), 

855 enforce_symmetry = True if all([s._enforce_symmetry for s in systems]) else False) 

856 

857 @classmethod 

858 def substructure_by_position(cls, systems, geometries, distance_threshold=1e-8, rcond=None): 

859 """ 

860 Applies constraints to systems by constraining colocated nodes together 

861 

862 Parameters 

863 ---------- 

864 systems : iterable of System objects 

865 A set of systems that will be combined and constrained together 

866 geometries : iterable of Geometry objects 

867 A set of geometries that will be combined together 

868 distance_threshold : float, optional 

869 The distance between nodes that are considered colocated. 

870 The default is 1e-8. 

871 rcond : float, optional 

872 Condition number to use in the nullspace calculation on 

873 the constraint matrix. The default is None. 

874 

875 Returns 

876 ------- 

877 combined_system : System 

878 System consisting of constraining the input systems together. 

879 combined_geometry : Geometry 

880 Combined geometry of the new system 

881 

882 """ 

883 from .sdynpy_geometry import Geometry 

884 combined_geometry, node_offset = Geometry.overlay_geometries( 

885 geometries, return_node_id_offset=True) 

886 combined_system = cls.concatenate(systems, node_offset) 

887 global_coords = combined_geometry.global_node_coordinate() 

888 node_distances = np.linalg.norm( 

889 global_coords[:, np.newaxis, :] - global_coords[np.newaxis, :, :], axis=-1) 

890 # Find locations where the value is less than the tolerances, except for on the centerline 

891 node_pairs = [[combined_geometry.node.id[index] for index in pair] 

892 for pair in zip(*np.where(node_distances < distance_threshold)) if pair[0] < pair[1]] 

893 # Find matching DoF pairs 

894 constraint_matrix = [] 

895 for node1, node2 in node_pairs: 

896 # Find the dofs associated with each node 

897 system_1_dof_indices = np.where(combined_system.coordinate.node == node1)[0] 

898 system_1_dofs = combined_system.coordinate[system_1_dof_indices] 

899 system_1_transformation = combined_system.transformation[system_1_dof_indices] 

900 global_deflections_1 = combined_geometry.global_deflection(system_1_dofs) 

901 system_2_dof_indices = np.where(combined_system.coordinate.node == node2)[0] 

902 system_2_dofs = combined_system.coordinate[system_2_dof_indices] 

903 system_2_transformation = combined_system.transformation[system_2_dof_indices] 

904 global_deflections_2 = combined_geometry.global_deflection(system_2_dofs) 

905 # Split between translations and rotations 

906 translation_map_1 = np.where((abs(system_1_dofs.direction) <= 3) 

907 & (abs(system_1_dofs.direction) > 0)) 

908 rotation_map_1 = np.where(abs(system_1_dofs.direction) > 3) 

909 translation_map_2 = np.where((abs(system_2_dofs.direction) <= 3) 

910 & (abs(system_2_dofs.direction) > 0)) 

911 rotation_map_2 = np.where(abs(system_2_dofs.direction) > 3) 

912 neutral_map_1 = np.where(abs(system_1_dofs.direction) == 0) 

913 neutral_map_2 = np.where(abs(system_2_dofs.direction) == 0) 

914 # Do translations and rotations separately 

915 for map_1, map_2 in [[translation_map_1, translation_map_2], 

916 [rotation_map_1, rotation_map_2], 

917 [neutral_map_1, neutral_map_2]]: 

918 deflections_1 = global_deflections_1[map_1].T 

919 deflections_2 = global_deflections_2[map_2].T 

920 transform_1 = system_1_transformation[map_1] 

921 transform_2 = system_2_transformation[map_2] 

922 full_constraint = deflections_1 @ transform_1 - deflections_2 @ transform_2 

923 constraint_matrix.append(full_constraint) 

924 constraint_matrix = np.concatenate(constraint_matrix, axis=0) 

925 return combined_system.constrain(constraint_matrix, rcond), combined_geometry 

926 

927 def constrain(self, constraint_matrix, rcond=None): 

928 """ 

929 Apply a constraint matrix to the system 

930 

931 Parameters 

932 ---------- 

933 constraint_matrix : np.ndarray 

934 A matrix of constraints to apply to the structure (B matrix in 

935 substructuring literature) 

936 rcond : float, optional 

937 Condition tolerance for computing the nullspace. The default is None. 

938 

939 Returns 

940 ------- 

941 System 

942 Constrained system. 

943 

944 """ 

945 substructuring_transform_matrix = null_space(constraint_matrix, rcond) 

946 new_mass = substructuring_transform_matrix.T @ self.mass @ substructuring_transform_matrix 

947 new_stiffness = substructuring_transform_matrix.T @ self.stiffness @ substructuring_transform_matrix 

948 new_damping = substructuring_transform_matrix.T @ self.damping @ substructuring_transform_matrix 

949 new_transform = self.transformation @ substructuring_transform_matrix 

950 return System(self.coordinate, new_mass, new_stiffness, new_damping, new_transform, 

951 enforce_symmetry=self._enforce_symmetry) 

952 

953 def transformation_matrix_at_coordinates(self, coordinates): 

954 """ 

955 Return the transformation matrix at the specified coordinates 

956 

957 Parameters 

958 ---------- 

959 coordinates : CoordinateArray 

960 coordinates at which the transformation matrix will be computed. 

961 

962 Raises 

963 ------ 

964 ValueError 

965 Raised if duplicate coordinates are requested, or if coordinates that 

966 do not exist in the system are requested. 

967 

968 Returns 

969 ------- 

970 return_value : np.ndarray 

971 Portion of the transformation matrix corresponding to the 

972 coordinates input to the function. 

973 

974 """ 

975 consistent_arrays, shape_indices, request_indices = np.intersect1d( 

976 abs(self.coordinate), abs(coordinates), assume_unique=False, return_indices=True) 

977 # Make sure that all of the keys are actually in the consistent array matrix 

978 if consistent_arrays.size != coordinates.size: 

979 extra_keys = np.setdiff1d(abs(coordinates), abs(self.coordinate)) 

980 if extra_keys.size == 0: 

981 raise ValueError( 

982 'Duplicate coordinate values requested. Please ensure coordinate indices are unique.') 

983 raise ValueError( 

984 'Not all indices in requested coordinate array exist in the system\n{:}'.format(str(extra_keys))) 

985 # Handle sign flipping 

986 multiplications = coordinates.flatten()[request_indices].sign( 

987 ) * self.coordinate[shape_indices].sign() 

988 return_value = self.transformation[shape_indices] * multiplications[:, np.newaxis] 

989 # Invert the indices to return the dofs in the correct order as specified in keys 

990 inverse_indices = np.zeros(request_indices.shape, dtype=int) 

991 inverse_indices[request_indices] = np.arange(len(request_indices)) 

992 return_value = return_value[inverse_indices] 

993 return return_value 

994 

995 def substructure_by_coordinate(self, dof_pairs, rcond=None, 

996 return_constrained_system=True): 

997 """ 

998 Constrain the system by connecting the specified degree of freedom pairs 

999 

1000 Parameters 

1001 ---------- 

1002 dof_pairs : iterable of CoordinateArray 

1003 Pairs of coordinates to be connected. None can be passed instead of 

1004 a second degree of freedom to constrain to ground 

1005 rcond : float, optional 

1006 Condition threshold to use for the nullspace calculation on the 

1007 constraint matrix. The default is None. 

1008 return_constrained_system : bool, optional 

1009 If true, apply the constraint matrix and return the constrained 

1010 system, otherwise simply return the constraint matrix. The default 

1011 is True. 

1012 

1013 Returns 

1014 ------- 

1015 np.ndarray or System 

1016 Returns a System object with the constraints applied if 

1017 `return_constrained_system` is True, otherwise just return the 

1018 constraint matrix. 

1019 

1020 """ 

1021 constraint_matrix = [] 

1022 for constraint_dof_0, constraint_dof_1 in dof_pairs: 

1023 constraint = self.transformation_matrix_at_coordinates(constraint_dof_0) 

1024 if constraint_dof_1 is not None: 

1025 constraint -= self.transformation_matrix_at_coordinates(constraint_dof_1) 

1026 constraint_matrix.append(constraint) 

1027 constraint_matrix = np.concatenate(constraint_matrix, axis=0) 

1028 if return_constrained_system: 

1029 return self.constrain(constraint_matrix, rcond) 

1030 else: 

1031 return constraint_matrix 

1032 

1033 def substructure_by_shape(self, constraint_shapes, connection_dofs_0, 

1034 connection_dofs_1=None, rcond=None, 

1035 return_constrained_system=True): 

1036 """ 

1037 Constrain the system using a set of shapes in a least-squares sense. 

1038 

1039 Parameters 

1040 ---------- 

1041 constraint_shapes : ShapeArray 

1042 An array of shapes to use as the basis for the constraints 

1043 connection_dofs_0 : CoordinateArray 

1044 Array of coordinates to use in the constraints 

1045 connection_dofs_1 : CoordinateArray, optional 

1046 Array of coordinates to constrain to the coordinates in 

1047 `connection_dofs_0`. If not specified, the `connection_dofs_0` 

1048 degrees of freedom will be constrained to ground. 

1049 rcond : float, optional 

1050 Condition threshold on the nullspace calculation. The default is None. 

1051 return_constrained_system : bool, optional 

1052 If true, apply the constraint matrix and return the constrained 

1053 system, otherwise simply return the constraint matrix. The default 

1054 is True. 

1055 

1056 Returns 

1057 ------- 

1058 np.ndarray or System 

1059 Returns a System object with the constraints applied if 

1060 `return_constrained_system` is True, otherwise just return the 

1061 constraint matrix. 

1062 

1063 """ 

1064 shape_matrix_0 = constraint_shapes[connection_dofs_0].T 

1065 transform_matrix_0 = self.transformation_matrix_at_coordinates(connection_dofs_0) 

1066 constraint_matrix = np.linalg.lstsq(shape_matrix_0, transform_matrix_0)[0] 

1067 if connection_dofs_1 is not None: 

1068 shape_matrix_1 = constraint_shapes[connection_dofs_1].T 

1069 transform_matrix_1 = self.transformation_matrix_at_coordinates(connection_dofs_1) 

1070 constraint_matrix -= np.linalg.lstsq(shape_matrix_1, transform_matrix_1)[0] 

1071 if return_constrained_system: 

1072 return self.constrain(constraint_matrix, rcond) 

1073 else: 

1074 return constraint_matrix 

1075 

1076 def substructure_shakers(self,shakers,coordinates): 

1077 """ 

1078 Uses substructuring to attach 4DoF Shaker Models to the Substructure 

1079 

1080 Parameters 

1081 ---------- 

1082 shakers : array of Shaker4DoF 

1083 4-DoF electromechanical models of a shaker that are added to the 

1084 System 

1085 coordinates : CoordinateArray 

1086 The degrees of freedom at which the shakers should be attached. 

1087 

1088 Returns 

1089 ------- 

1090 System 

1091 A system with shakers attached to it. The shaker degrees of freedom 

1092 will be assigned based on the next power of 10 bigger than the 

1093 current maximum node number. Degrees of freedom end in 1 for 

1094 armature force/displacement, 2 for body force/displacement, 3 for 

1095 force gauge force/displacement, and 4 for drive voltage/current 

1096 """ 

1097 constraint_pairs = [] 

1098 shaker_systems = [] 

1099 

1100 max_node = self.coordinate.node.max() 

1101 shaker_node_offset = int(10**np.ceil(np.log10(max_node))) 

1102 if shaker_node_offset == max_node: 

1103 shaker_node_offset *= 10 

1104 if shaker_node_offset < 100: 

1105 shaker_node_offset = 100 

1106 

1107 for i,(direction,shaker) in enumerate(zip(coordinates,shakers)): 

1108 M,C,K = shaker.MCK() 

1109 coords = coordinate_array(shaker_node_offset + i*10 + np.arange(4)+1,[1,1,1,0]) 

1110 shaker_systems.append(System(coords,M,K,C,enforce_symmetry = False)) 

1111 constraint_dof = coordinate_array(shaker_node_offset + i*10 + 3,1) 

1112 constraint_pairs.append([direction,constraint_dof]) 

1113 

1114 constraint_pairs = np.array(constraint_pairs).view(CoordinateArray) 

1115 

1116 combined_system = System.concatenate([self]+shaker_systems) 

1117 

1118 constrained_system = combined_system.substructure_by_coordinate(constraint_pairs) 

1119 

1120 return constrained_system 

1121 

1122 def copy(self): 

1123 """ 

1124 Returns a copy of the system object 

1125 

1126 Returns 

1127 ------- 

1128 System 

1129 A copy of the system object 

1130 

1131 """ 

1132 return System(self.coordinate.copy(), self.mass.copy(), self.stiffness.copy(), 

1133 self.damping.copy(), self.transformation.copy(), 

1134 self._enforce_symmetry) 

1135 

1136 def set_proportional_damping(self, mass_fraction, stiffness_fraction): 

1137 """ 

1138 Sets the damping matrix to a proportion of the mass and stiffness matrices. 

1139 

1140 The damping matrix will be set to `mass_fraction*self.mass + 

1141 stiffness_fraction*self.stiffness` 

1142 

1143 Parameters 

1144 ---------- 

1145 mass_fraction : float 

1146 Fraction of the mass matrix 

1147 stiffness_fraction : TYPE 

1148 Fraction of the stiffness matrix 

1149 

1150 Returns 

1151 ------- 

1152 None. 

1153 

1154 """ 

1155 self.damping = self.mass * mass_fraction + self.stiffness * stiffness_fraction 

1156 

1157 @classmethod 

1158 def beam(cls, length, width, height, num_nodes, E=None, rho=None, nu=None, material=None): 

1159 """ 

1160 Create a beam mass and stiffness matrix 

1161 

1162 Parameters 

1163 ---------- 

1164 length : float 

1165 Lenghth of the beam 

1166 width : float 

1167 Width of the beam 

1168 height : float 

1169 Height of the beam 

1170 num_nodes : int 

1171 Number of nodes in the beam. 

1172 E : float, optional 

1173 Young's modulus of the beam. If not specified, a `material` must be 

1174 specified instead 

1175 rho : float, optional 

1176 Density of the beam. If not specified, a `material` must be 

1177 specified instead 

1178 nu : float, optional 

1179 Poisson's ratio of the beam. If not specified, a `material` must be 

1180 specified instead 

1181 material : str, optional 

1182 A specific material can be specified instead of `E`, `rho`, and `nu`. 

1183 Should be a string 'steel' or 'aluminum'. If not specified, then 

1184 options `E`, `rho`, and `nu` must be specified instead. 

1185 

1186 Raises 

1187 ------ 

1188 ValueError 

1189 If improper materials are defined. 

1190 

1191 Returns 

1192 ------- 

1193 system : System 

1194 A system object consisting of the beam mass and stiffness matrices. 

1195 geometry : Geometry 

1196 A Geometry consisting of the beam geometry. 

1197 

1198 """ 

1199 from .sdynpy_geometry import Geometry, node_array, traceline_array, coordinate_system_array 

1200 node_positions = np.array((np.linspace(0, length, num_nodes), 

1201 np.zeros(num_nodes), 

1202 np.zeros(num_nodes))).T 

1203 node_connectivity = np.array((np.arange(num_nodes - 1), np.arange(1, num_nodes))).T 

1204 bend_direction_1 = np.array((np.zeros(num_nodes - 1), 

1205 np.zeros(num_nodes - 1), 

1206 np.ones(num_nodes - 1))).T 

1207 if material is None: 

1208 if E is None or rho is None or nu is None: 

1209 raise ValueError('Must specify material or E, nu, and rho') 

1210 elif material.lower() == 'steel': 

1211 E = 200e9 # [N/m^2], 

1212 nu = 0.25 # [-], 

1213 rho = 7850 # [kg/m^3] 

1214 elif material.lower() == 'aluminum': 

1215 E = 69e9 # [N/m^2], 

1216 nu = 0.33 # [-], 

1217 rho = 2830 # [kg/m^3] 

1218 else: 

1219 raise ValueError('Unknown Material {:}'.format(material)) 

1220 mat_props = rect_beam_props(E, rho, nu, width, height, num_nodes - 1) 

1221 K, M = beamkm(node_positions, node_connectivity, bend_direction_1, **mat_props) 

1222 coordinates = from_nodelist(np.arange(num_nodes) + 1, directions=[1, 2, 3, 4, 5, 6]) 

1223 system = cls(coordinates, M, K) 

1224 nodelist = node_array(np.arange(num_nodes) + 1, node_positions) 

1225 tracelines = traceline_array(connectivity=np.arange(num_nodes) + 1) 

1226 coordinate_systems = coordinate_system_array() 

1227 geometry = Geometry(nodelist, coordinate_systems, tracelines) 

1228 return system, geometry 

1229 

1230 def get_indices_by_coordinate(self, coordinates, ignore_sign=False): 

1231 """ 

1232 Gets the indices in the transformation matrix corresponding coordinates 

1233 

1234 Parameters 

1235 ---------- 

1236 coordinates : CoordinateArray 

1237 Coordinates to extract transformation indices 

1238 ignore_sign : bool, optional 

1239 Specify whether or not to ignore signs on the coordinates. If True, 

1240 then '101X+' would match '101X+' or '101X-'. The default is False. 

1241 

1242 Raises 

1243 ------ 

1244 ValueError 

1245 Raised if duplicate coordinates or coordinates not in the system 

1246 are requested 

1247 

1248 Returns 

1249 ------- 

1250 np.ndarray 

1251 Array of indices. 

1252 

1253 """ 

1254 if ignore_sign: 

1255 consistent_arrays, shape_indices, request_indices = np.intersect1d( 

1256 abs(self.coordinate), abs(coordinates), assume_unique=False, return_indices=True) 

1257 else: 

1258 consistent_arrays, shape_indices, request_indices = np.intersect1d( 

1259 self.coordinate, coordinates, assume_unique=False, return_indices=True) 

1260 # Make sure that all of the keys are actually in the consistent array matrix 

1261 if consistent_arrays.size != coordinates.size: 

1262 extra_keys = np.setdiff1d(abs(coordinates), abs(self.coordinate)) 

1263 if extra_keys.size == 0: 

1264 raise ValueError( 

1265 'Duplicate coordinate values requested. Please ensure coordinate indices are unique.') 

1266 raise ValueError( 

1267 'Not all indices in requested coordinate array exist in the system\n{:}'.format(str(extra_keys))) 

1268 # Handle sign flipping 

1269 return_value = shape_indices 

1270 # Invert the indices to return the dofs in the correct order as specified in keys 

1271 inverse_indices = np.zeros(request_indices.shape, dtype=int) 

1272 inverse_indices[request_indices] = np.arange(len(request_indices)) 

1273 return return_value[inverse_indices] 

1274 

1275 def reduce(self, reduction_transformation): 

1276 """ 

1277 Apply the specified reduction to the model 

1278 

1279 Parameters 

1280 ---------- 

1281 reduction_transformation : np.ndarray 

1282 Matrix to use in the reduction 

1283 

1284 Returns 

1285 ------- 

1286 System 

1287 Reduced system. 

1288 

1289 """ 

1290 mass = reduction_transformation.T @ self.mass @ reduction_transformation 

1291 stiffness = reduction_transformation.T @ self.stiffness @ reduction_transformation 

1292 damping = reduction_transformation.T @ self.damping @ reduction_transformation 

1293 transformation = self.transformation @ reduction_transformation 

1294 # Force symmetry 

1295 mass = (mass + mass.T) / 2 

1296 stiffness = (stiffness + stiffness.T) / 2 

1297 damping = (damping + damping.T) / 2 

1298 return System(self.coordinate, mass, stiffness, damping, transformation, self._enforce_symmetry) 

1299 

1300 def reduce_guyan(self, coordinates): 

1301 """ 

1302 Perform Guyan reduction on the system 

1303 

1304 Parameters 

1305 ---------- 

1306 coordinates : CoordinateArray 

1307 A list of coordinates to keep in the reduced system. 

1308 

1309 Raises 

1310 ------ 

1311 ValueError 

1312 Raised the transformation matrix is not identity matrix. 

1313 

1314 Returns 

1315 ------- 

1316 System 

1317 Reduced system. 

1318 

1319 """ 

1320 if isinstance(coordinates, CoordinateArray): 

1321 if not np.allclose(self.transformation, np.eye(*self.transformation.shape)): 

1322 raise ValueError( 

1323 'Coordinates can only be specified with a CoordinateArray if the transformation is identity') 

1324 keep_dofs = self.get_indices_by_coordinate(coordinates) 

1325 else: 

1326 keep_dofs = np.array(coordinates) 

1327 discard_dofs = np.array([i for i in range(self.ndof) if i not in keep_dofs]) 

1328 I_a = np.eye(keep_dofs.size) 

1329 K_dd = self.stiffness[discard_dofs[:, np.newaxis], 

1330 discard_dofs] 

1331 K_da = self.stiffness[discard_dofs[:, np.newaxis], 

1332 keep_dofs] 

1333 T_guyan = np.concatenate((I_a, -np.linalg.solve(K_dd, K_da)), axis=0) 

1334 T_guyan[np.concatenate((keep_dofs, discard_dofs)), :] = T_guyan.copy() 

1335 return self.reduce(T_guyan) 

1336 

1337 def reduce_dynamic(self, coordinates, frequency): 

1338 """ 

1339 Perform Dynamic condensation 

1340 

1341 Parameters 

1342 ---------- 

1343 coordinates : CoordinateArray 

1344 A list of coordinates to keep in the reduced system. 

1345 frequency : float 

1346 The frequency to preserve in the dynamic reduction. 

1347 

1348 Raises 

1349 ------ 

1350 ValueError 

1351 Raised if the transformation is not identity matrix. 

1352 

1353 Returns 

1354 ------- 

1355 System 

1356 Reduced system. 

1357 

1358 """ 

1359 if isinstance(coordinates, CoordinateArray): 

1360 if not np.allclose(self.transformation, np.eye(*self.transformation.shape)): 

1361 raise ValueError( 

1362 'Coordinates can only be specified with a CoordinateArray if the transformation is identity') 

1363 keep_dofs = self.get_indices_by_coordinate(coordinates) 

1364 else: 

1365 keep_dofs = np.array(coordinates) 

1366 discard_dofs = np.array([i for i in range(self.ndof) if i not in keep_dofs]) 

1367 I_a = np.eye(keep_dofs.size) 

1368 D = self.stiffness - (2 * np.pi * frequency)**2 * self.mass 

1369 D_dd = D[discard_dofs[:, np.newaxis], 

1370 discard_dofs] 

1371 D_da = D[discard_dofs[:, np.newaxis], 

1372 keep_dofs] 

1373 T_dynamic = np.concatenate((I_a, -np.linalg.solve(D_dd, D_da)), axis=0) 

1374 T_dynamic[np.concatenate((keep_dofs, discard_dofs)), :] = T_dynamic.copy() 

1375 return self.reduce(T_dynamic) 

1376 

1377 def reduce_craig_bampton(self, connection_degrees_of_freedom: CoordinateArray, 

1378 num_fixed_base_modes: int, 

1379 return_shape_matrix: bool = False): 

1380 """ 

1381 Computes a craig-bampton substructure model for the system 

1382 

1383 Parameters 

1384 ---------- 

1385 connection_degrees_of_freedom : CoordinateArray 

1386 Degrees of freedom to keep at the interface. 

1387 num_fixed_base_modes : int 

1388 Number of fixed-base modes to use in the reduction 

1389 return_shape_matrix : bool, optional 

1390 If true, return a set of shapes that represents the transformation 

1391 in addition to the reduced system. The default is False. 

1392 

1393 Raises 

1394 ------ 

1395 ValueError 

1396 Raised if coordinate arrays are specified when there is already a 

1397 transformation. 

1398 

1399 Returns 

1400 ------- 

1401 System 

1402 Reduced system in craig-bampton form 

1403 ShapeArray 

1404 Shapes representing the craig-bampton transformation 

1405 

1406 """ 

1407 # Construct craig bampton transformation 

1408 if isinstance(connection_degrees_of_freedom, CoordinateArray): 

1409 if not np.allclose(self.transformation, np.eye(*self.transformation.shape)): 

1410 raise ValueError( 

1411 'Coordinates can only be specified with a CoordinateArray if the transformation is identity') 

1412 connection_indices = self.get_indices_by_coordinate(connection_degrees_of_freedom) 

1413 else: 

1414 connection_indices = np.array(connection_degrees_of_freedom) 

1415 other_indices = np.array([i for i in range(self.ndof) if i not in connection_indices]) 

1416 

1417 # Extract portions of the mass and stiffness matrices 

1418 K_ii = self.K[other_indices[:, np.newaxis], other_indices] 

1419 M_ii = self.M[other_indices[:, np.newaxis], other_indices] 

1420 K_ib = self.K[other_indices[:, np.newaxis], connection_indices] 

1421 

1422 # Compute fixed interface modes 

1423 lam, Phi_ii = eigh(K_ii, M_ii, subset_by_index=[0, int(num_fixed_base_modes) - 1]) 

1424 # Normalize the mode shapes 

1425 lam[lam < 0] = 0 

1426 normalized_mass = np.diag(Phi_ii.T @ M_ii @ Phi_ii) 

1427 Phi_ii /= np.sqrt(normalized_mass) 

1428 Z_bi = np.zeros((connection_indices.size, num_fixed_base_modes)) 

1429 # Compute constraint modes 

1430 Psi_ib = -np.linalg.solve(K_ii, K_ib) 

1431 I_bb = np.eye(connection_indices.size) 

1432 T_cb = np.block([[Phi_ii, Psi_ib], 

1433 [Z_bi, I_bb]]) 

1434 T_cb[np.concatenate((other_indices, connection_indices)), :] = T_cb.copy() 

1435 if return_shape_matrix: 

1436 from .sdynpy_shape import shape_array 

1437 freq = np.sqrt(lam) / (2 * np.pi) 

1438 all_freqs = np.concatenate((freq, np.zeros(connection_indices.size))) 

1439 shapes = shape_array(self.coordinate, T_cb.T, all_freqs, comment1=['Fixed Base Mode {:}'.format( 

1440 i + 1) for i in range(num_fixed_base_modes)] + ['Constraint Mode {:}'.format(str(dof)) for dof in connection_degrees_of_freedom]) 

1441 return self.reduce(T_cb), shapes 

1442 else: 

1443 return self.reduce(T_cb) 

1444 

1445 @classmethod 

1446 def from_exodus_superelement(cls, superelement_nc4, transformation_exodus_file=None, 

1447 x_disp='DispX', y_disp='DispY', z_disp='DispZ', 

1448 x_rot=None, y_rot=None, z_rot=None, 

1449 reduce_to_external_surfaces=False): 

1450 """ 

1451 Creates a system from a superelement from Sierra/SD 

1452 

1453 Parameters 

1454 ---------- 

1455 superelement_nc4 : netCDF4.Dataset or string 

1456 Dataset from which the superelement data will be loaded 

1457 transformation_exodus_file : Exodus, ExodusInMemory, or str, optional 

1458 Exodus data containing the transformation between the reduced 

1459 superelement state and the physical space. If not specified, no 

1460 transformation will be created. 

1461 x_disp : str, optional 

1462 Variable name to read for x-displacements in the transformation 

1463 Exodus file. The default is 'DispX'. 

1464 y_disp : str, optional 

1465 Variable name to read for y-displacements in the transformation 

1466 Exodus file. The default is 'DispY'. 

1467 z_disp : str, optional 

1468 Variable name to read for z-displacements in the transformation 

1469 Exodus file. The default is 'DispZ'. 

1470 x_rot : str, optional 

1471 Variable name to read for x-rotations in the transformation 

1472 Exodus file. The default is to not read rotations. 

1473 y_rot : str, optional 

1474 Variable name to read for y-rotations in the transformation 

1475 Exodus file. The default is to not read rotations. 

1476 z_rot : str, optional 

1477 Variable name to read for z-rotations in the transformation 

1478 Exodus file. The default is to not read rotations. 

1479 reduce_to_external_surfaces : bool, optional 

1480 If True, exodus results will be reduced to external surfaces 

1481 

1482 Raises 

1483 ------ 

1484 ValueError 

1485 raised if bad data types are passed to the arguments. 

1486 

1487 Returns 

1488 ------- 

1489 system : System 

1490 System containing the superelement representation 

1491 geometry : Geometry 

1492 Geometry that can be used to plot the system 

1493 boundary_dofs : CoordinateArray 

1494 Degrees of freedom that can be used to constrain the test article. 

1495 

1496 """ 

1497 from ..fem.sdynpy_exodus import Exodus, ExodusInMemory, reduce_exodus_to_surfaces, read_sierra_matlab_matrix_file, read_sierra_matlab_map_file 

1498 from .sdynpy_geometry import node_array, coordinate_system_array, Geometry 

1499 if isinstance(superelement_nc4, str): 

1500 ds = nc4.Dataset(superelement_nc4) 

1501 elif isinstance(superelement_nc4, nc4.Dataset): 

1502 ds = superelement_nc4 

1503 else: 

1504 raise ValueError('superelement_nc4 must be a string or a netCDF4 Dataset') 

1505 cbmap = ds['cbmap'][:].data.copy() 

1506 Kr = ds['Kr'][:].data.copy() 

1507 Mr = ds['Mr'][:].data.copy() 

1508 Cr = ds['Cr'][:].data.copy() 

1509 num_constraint_modes = ds.dimensions['NumConstraints'].size 

1510 num_fixed_base_modes = ds.dimensions['NumEig'].size 

1511 boundary_dofs = coordinate_array(*cbmap[cbmap[:, 0] > 0].T) 

1512 if transformation_exodus_file is None: 

1513 transformation = None 

1514 coordinate_nodes = cbmap[:, 0] 

1515 coordinate_dirs = cbmap[:, 1] 

1516 coordinate_nodes[coordinate_nodes == 0] = np.arange(num_fixed_base_modes) + 1 

1517 coordinates = coordinate_array(coordinate_nodes, coordinate_dirs) 

1518 cs_array = coordinate_system_array() 

1519 n_array = node_array(ds['node_num_map'][:], np.array( 

1520 [ds['coord{:}'.format(d)][:] for d in 'xyz']).T) 

1521 geometry = Geometry(node=n_array, coordinate_system=cs_array) 

1522 else: 

1523 if isinstance(transformation_exodus_file, str): 

1524 exo = Exodus(transformation_exodus_file) 

1525 elif isinstance(transformation_exodus_file, Exodus): 

1526 exo = transformation_exodus_file 

1527 elif isinstance(transformation_exodus_file, ExodusInMemory): 

1528 exo = transformation_exodus_file 

1529 else: 

1530 raise ValueError('transformation_exodus_file must be a string or a sdpy.Exodus') 

1531 if reduce_to_external_surfaces: 

1532 exo = reduce_exodus_to_surfaces(exo, variables_to_transform=[var for var in [x_disp, y_disp, z_disp, x_rot, y_rot, z_rot] if var is not None]) 

1533 from .sdynpy_shape import ShapeArray 

1534 shapes = ShapeArray.from_exodus(exo, x_disp, y_disp, z_disp, x_rot, y_rot, z_rot) 

1535 transformation = shapes.shape_matrix.T 

1536 coordinates = shapes[0].coordinate 

1537 geometry = Geometry.from_exodus(exo) 

1538 system = cls(coordinates, Mr, Kr, Cr, transformation) 

1539 return system, geometry, boundary_dofs 

1540 

1541 @classmethod 

1542 def from_sierra_sd_mfile_output(cls, maa_file, kaa_file, gid_file, aset_map_file): 

1543 """ 

1544 Generates a System object from the MFILE, MAA, and KAA outputs from 

1545 Sierra/SD 

1546 

1547 Parameters 

1548 ---------- 

1549 maa_file : str 

1550 Path to the file containing the MAA matrix. 

1551 kaa_file : str 

1552 Path to the file containing the KAA matrix. 

1553 gid_file : str 

1554 Path to the file contiaining the global node id map 

1555 aset_map_file : str 

1556 Path to the A-set Map. 

1557 

1558 Returns 

1559 ------- 

1560 system : System 

1561 A system object with the specified matrices and degrees of freedom. 

1562 

1563 """ 

1564 M = read_sierra_matlab_matrix_file(maa_file) 

1565 K = read_sierra_matlab_matrix_file(kaa_file) 

1566 gid = read_sierra_matlab_map_file(gid_file) 

1567 asetmap = read_sierra_matlab_map_file(aset_map_file).reshape(-1,9) 

1568 valid_dofs = asetmap != -1 

1569 # Check if any of the acoustic, temperature, or voltage are defined 

1570 if np.any(valid_dofs[:,-3:]): 

1571 warnings.warn('Sierra system contains acoustic, temperature, or voltage degrees of freedom. These will be ignored by SDynPy.') 

1572 valid_dofs = valid_dofs[:,:-3] 

1573 asetmap = asetmap[:,:-3] 

1574 coordinates = coordinate_array(gid[:,np.newaxis],[1,2,3,4,5,6])[valid_dofs] 

1575 dof_ordering = asetmap[valid_dofs]-1 

1576 M = M[dof_ordering[:,np.newaxis],dof_ordering] 

1577 K = K[dof_ordering[:,np.newaxis],dof_ordering] 

1578 system = cls(coordinates,M,K) 

1579 return system 

1580 

1581 def simulate_test( 

1582 self, # The system itself 

1583 bandwidth, 

1584 frame_length, 

1585 num_averages, 

1586 excitation, 

1587 references, 

1588 responses=None, # All Responses 

1589 excitation_level=1.0, 

1590 excitation_noise_level=0.0, 

1591 response_noise_level=0.0, 

1592 steady_state_time=0.0, 

1593 excitation_min_frequency=None, 

1594 excitation_max_frequency=None, 

1595 signal_fraction=0.5, 

1596 extra_time_between_frames=0.0, 

1597 integration_oversample=10, 

1598 antialias_filter_cutoff_factor=3, 

1599 antialias_filter_order=4, 

1600 multihammer_impact_spacing_factor = 4, 

1601 extra_outputs = None, 

1602 **generator_kwargs 

1603 ): 

1604 available_excitations = ['pseudorandom', 'random', 

1605 'burst random', 'chirp', 'hammer', 'multi-hammer', 'sine'] 

1606 if not excitation.lower() in available_excitations: 

1607 raise ValueError('Excitation must be one of {:}'.format(available_excitations)) 

1608 # Create the input signal 

1609 references = np.atleast_1d(references) 

1610 num_signals = references.size 

1611 sample_rate = bandwidth * 2 * integration_oversample 

1612 dt = 1 / sample_rate 

1613 frame_time = dt * frame_length * integration_oversample 

1614 # Create the signals 

1615 if excitation.lower() == 'pseudorandom': 

1616 if num_signals > 1: 

1617 print('Warning: Pseudorandom generally not recommended for multi-reference excitation.') 

1618 kwargs = {'fft_lines': frame_length // 2, 

1619 'f_nyq': bandwidth, 

1620 'signal_rms': excitation_level, 

1621 'min_freq': excitation_min_frequency, 

1622 'max_freq': excitation_max_frequency, 

1623 'integration_oversample': integration_oversample, 

1624 'averages': num_averages + int(np.ceil(steady_state_time / frame_time))} 

1625 kwargs.update(generator_kwargs) 

1626 signals = np.array([ 

1627 generator.pseudorandom(**kwargs)[1] 

1628 for i in range(num_signals) 

1629 ]) 

1630 elif excitation.lower() == 'random': 

1631 kwargs = {'shape': (num_signals,), 

1632 'n_samples': frame_length * integration_oversample * num_averages + int(steady_state_time * sample_rate), 

1633 'rms': excitation_level, 

1634 'dt': dt, 

1635 'low_frequency_cutoff': excitation_min_frequency, 

1636 'high_frequency_cutoff': bandwidth if excitation_max_frequency is None else excitation_max_frequency} 

1637 kwargs.update(generator_kwargs) 

1638 signals = generator.random(**kwargs) 

1639 elif excitation.lower() == 'burst random': 

1640 kwargs = {'shape': (num_signals,), 

1641 'n_samples': frame_length * integration_oversample, 

1642 'on_fraction': signal_fraction, 

1643 'delay_fraction': 0, 

1644 'rms': excitation_level, 

1645 'dt': dt, 

1646 'low_frequency_cutoff': excitation_min_frequency, 

1647 'high_frequency_cutoff': bandwidth if excitation_max_frequency is None else excitation_max_frequency} 

1648 kwargs.update(generator_kwargs) 

1649 signal_list = [generator.burst_random(**kwargs) for i in range(num_averages)] 

1650 full_list = [] 

1651 for i, signal in enumerate(signal_list): 

1652 full_list.append( 

1653 np.zeros((num_signals, int(extra_time_between_frames * sample_rate)))) 

1654 full_list.append(signal) 

1655 signals = np.concatenate(full_list, axis=-1) 

1656 elif excitation.lower() == 'chirp': 

1657 if num_signals > 1: 

1658 print('Warning: Chirp generally not recommended for multi-reference excitation.') 

1659 kwargs = {'frequency_min': 0 if excitation_min_frequency is None else excitation_min_frequency, 

1660 'frequency_max': bandwidth if excitation_max_frequency is None else excitation_max_frequency, 

1661 'signal_length': frame_time, 

1662 'dt': dt} 

1663 kwargs.update(generator_kwargs) 

1664 signals = np.array([ 

1665 generator.chirp(**kwargs) 

1666 for i in range(num_signals) 

1667 ]) * excitation_level 

1668 signals = np.tile(signals, [1, num_averages + 

1669 int(np.ceil(steady_state_time / frame_time))]) 

1670 elif excitation.lower() == 'hammer': 

1671 if num_signals > 1: 

1672 print( 

1673 'Warning: Hammer impact generally not recommended for multi-reference excitation, consider multi-hammer instead') 

1674 pulse_width = 2 / (bandwidth if excitation_max_frequency is None else excitation_max_frequency) 

1675 signal_length = int(frame_length * integration_oversample * num_averages + (num_averages + 1) 

1676 * extra_time_between_frames * sample_rate + 2 * pulse_width * sample_rate) 

1677 pulse_times = np.arange(num_averages)[ 

1678 :, np.newaxis] * (frame_time + extra_time_between_frames) + pulse_width + extra_time_between_frames 

1679 kwargs = {'signal_length': signal_length, 

1680 'pulse_time': pulse_times, 

1681 'pulse_width': pulse_width, 

1682 'pulse_peak': excitation_level, 

1683 'dt': dt, 

1684 'sine_exponent': 2} 

1685 kwargs.update(generator_kwargs) 

1686 signals = generator.pulse(**kwargs) 

1687 signals = np.tile(signals, [num_signals, 1]) 

1688 elif excitation.lower() == 'multi-hammer': 

1689 signal_length = frame_length * integration_oversample 

1690 pulse_width = 2 / (bandwidth if excitation_max_frequency is None else excitation_max_frequency) 

1691 signals = [] 

1692 for i in range(num_signals): 

1693 signals.append([]) 

1694 for j in range(num_averages): 

1695 pulse_times = [] 

1696 last_pulse = 0 

1697 while last_pulse < frame_time * signal_fraction: 

1698 next_pulse = last_pulse + pulse_width * (np.random.rand() * multihammer_impact_spacing_factor + 1) 

1699 pulse_times.append(next_pulse) 

1700 last_pulse = next_pulse 

1701 pulse_times = np.array(pulse_times) 

1702 pulse_times = pulse_times[pulse_times < 

1703 frame_time * signal_fraction, np.newaxis] 

1704 kwargs = {'signal_length': signal_length, 

1705 'pulse_time': pulse_times, 

1706 'pulse_width': pulse_width, 

1707 'pulse_peak': excitation_level, 

1708 'dt': dt, 

1709 'sine_exponent': 2} 

1710 kwargs.update(generator_kwargs) 

1711 signal = generator.pulse(**kwargs) 

1712 signals[-1].append(np.zeros(int(extra_time_between_frames * sample_rate))) 

1713 signals[-1].append(signal) 

1714 signals[-1].append(np.zeros(int(extra_time_between_frames * sample_rate))) 

1715 signals[-1] = np.concatenate(signals[-1], axis=-1) 

1716 signals = np.array(signals) 

1717 elif excitation.lower() == 'sine': 

1718 if num_signals > 1: 

1719 print( 

1720 'Warning: Sine signal generally not recommended for multi-reference excitation') 

1721 frequencies = excitation_max_frequency if excitation_min_frequency is None else excitation_min_frequency 

1722 num_samples = frame_length * integration_oversample * num_averages + int(steady_state_time * sample_rate) 

1723 kwargs = {'frequencies': frequencies, 

1724 'dt': dt, 

1725 'num_samples': num_samples, 

1726 'amplitudes': excitation_level} 

1727 kwargs.update(generator_kwargs) 

1728 signals = np.tile(generator.sine(**kwargs), (num_signals, 1)) 

1729 # Set up the integration 

1730 from .sdynpy_data import time_history_array 

1731 abscissa = np.arange(signals.shape[-1])*dt 

1732 references = time_history_array(abscissa,signals,references[:,np.newaxis]).copy() 

1733 responses = self.time_integrate( 

1734 references, responses, extra_outputs = extra_outputs) 

1735 # Now add noise 

1736 responses.ordinate += response_noise_level * np.random.randn(*responses.ordinate.shape) 

1737 references.ordinate += excitation_noise_level * np.random.randn(*references.ordinate.shape) 

1738 # Filter with antialiasing filters, divide filter order by 2 because of filtfilt 

1739 if antialias_filter_order > 0: 

1740 lowpass_b, lowpass_a = butter(antialias_filter_order // 2, 

1741 antialias_filter_cutoff_factor * bandwidth, fs=sample_rate) 

1742 responses.ordinate = filtfilt(lowpass_b, lowpass_a, responses.ordinate) 

1743 references.ordinate = filtfilt(lowpass_b, lowpass_a, references.ordinate) 

1744 if integration_oversample > 1: 

1745 responses = responses.downsample(integration_oversample) 

1746 references = references.downsample(integration_oversample) 

1747 responses = responses.extract_elements_by_abscissa(steady_state_time, np.inf) 

1748 references = references.extract_elements_by_abscissa(steady_state_time, np.inf) 

1749 return responses, references 

1750 

1751 # def reduce_serep(self,shapes_full,shapes_reduced): 

1752 # if isinstance(shapes_full,) 

1753 

1754 

1755substructure_by_position = System.substructure_by_position