Coverage for src / sdynpy / modal / sdynpy_modeshape.py: 6%
322 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-11 16:22 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-11 16:22 +0000
1# -*- coding: utf-8 -*-
2"""
3Functions for computing shapes after the poles are known
4"""
5"""
6Copyright 2022 National Technology & Engineering Solutions of Sandia,
7LLC (NTESS). Under the terms of Contract DE-NA0003525 with NTESS, the U.S.
8Government retains certain rights in this software.
10This program is free software: you can redistribute it and/or modify
11it under the terms of the GNU General Public License as published by
12the Free Software Foundation, either version 3 of the License, or
13(at your option) any later version.
15This program is distributed in the hope that it will be useful,
16but WITHOUT ANY WARRANTY; without even the implied warranty of
17MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18GNU General Public License for more details.
20You should have received a copy of the GNU General Public License
21along with this program. If not, see <https://www.gnu.org/licenses/>.
22"""
24from enum import Enum
25import numpy as np
26from ..core.sdynpy_data import TransferFunctionArray
27from ..core.sdynpy_shape import shape_array
28from ..core.sdynpy_coordinate import CoordinateArray
29from ..signal_processing.sdynpy_complex import collapse_complex_to_real
30import warnings
33class ShapeSelection(Enum):
34 ALL = 0
35 DRIVE_POINT_COEFFICIENT = 1
36 PARTICIPATION_FACTOR = 2
39def compute_residues(experimental_frf: TransferFunctionArray,
40 natural_frequencies: np.ndarray,
41 damping_ratios: np.ndarray,
42 real_modes: bool = False,
43 residuals: bool = True,
44 min_frequency: float = None,
45 max_frequency: float = None,
46 weighting: np.ndarray = 'uniform',
47 displacement_derivative: int = 0,
48 frequency_lines_at_resonance: int = None,
49 frequency_lines_for_residuals: int = None):
50 """
51 Fit residues to FRF data given frequency and damping values
53 Parameters
54 ----------
55 experimental_frf : TransferFunctionArray
56 Experimental FRF data to which modes will be fit
57 natural_frequencies : np.ndarray
58 Natural Frequencies (in Hz) at which modes will be fit
59 damping_ratios : np.ndarray
60 Damping Ratios at which modes will be fit
61 real_modes : bool, optional
62 If true, fit residues will be real-valued. False allows complex modes.
63 The default is False.
64 residuals : bool, optional
65 Use residuals in the FRF fit. The default is True.
66 min_frequency : float, optional
67 Minimum frequency to use in the shape fit. The default is the lowest
68 frequency in the experimental FRF.
69 max_frequency : float, optional
70 Maximum frequency to use in the shape fit. The default is the highest
71 frequency in the experimental FRF.
72 weighting : np.ndarray or string, optional
73 A weighting array to use to fit shapes better at specific frequencies.
74 The default is weighted by the log magnitude of the FRF matrix. Can be
75 defined as 'magnitude','uniform', or an ndarray with shape identical
76 to the ordinate of the experimental frfs
77 displacement_derivative : int, optional
78 Defines the type of data in the FRF based on the number of derivatives
79 from displacement (0 - displacement, 1 - velocity, 2 - acceleration).
80 The default is 0 (displacement).
81 frequency_lines_at_resonance : int, optional
82 Defines the number of frequency lines to look at around the specified
83 natural frequencies for computing residues. If not specified, all
84 frequency lines are used for computing shapes.
85 frequency_lines_for_residuals : int, optional
86 Defines the number of frequency lines at the low and high frequency to
87 use in computing shapes. Only used if frequency_lines_at_resonance is
88 specified. If not specified, the lower 10% and upper 10% of frequency
89 lines will be kept.
91 Returns
92 -------
93 shape_residues : np.ndarray
94 A (..., n_modes) shaped np.ndarray where ... is the shape of the input
95 experimental_frf array. There will be one residue for each experimental
96 frf (reference and response) for each mode.
97 synthesized_frf : TransferFunctionArray
98 Transfer function array containing the analytical fits using the
99 residues.
100 residual_frf : TransferFunctionArray
101 Transfer function array containing the residual data from the analytical
102 fits.
103 """
104 flat_frf = experimental_frf.flatten()
105 frequencies = flat_frf[0].abscissa.copy()
106 if min_frequency is None:
107 min_frequency = np.min(frequencies)
108 if max_frequency is None:
109 max_frequency = np.max(frequencies)
110 abscissa_indices = np.ones(frequencies.shape, dtype=bool)
111 abscissa_indices &= (frequencies >= min_frequency)
112 abscissa_indices &= (frequencies <= max_frequency)
113 frequencies = frequencies[abscissa_indices]
114 frf_matrix = flat_frf.ordinate[:, abscissa_indices].T.copy()
115 angular_frequencies = 2 * np.pi * frequencies[:, np.newaxis]
116 angular_natural_frequencies = 2 * np.pi * np.array(natural_frequencies).flatten()
117 damping_ratios = np.array(damping_ratios).flatten()
119 # Reduce to the kept frequency lines
120 if frequency_lines_at_resonance is not None:
121 solve_indices = np.argmin(np.abs(angular_natural_frequencies - angular_frequencies), axis=0)
122 # print(solve_indices)
123 solve_indices = np.unique(
124 solve_indices[:, np.newaxis] + np.arange(frequency_lines_at_resonance) - frequency_lines_at_resonance // 2)
125 solve_indices = solve_indices[(solve_indices >= 0) & (
126 solve_indices < angular_frequencies.size)]
127 # Add the residual indices
128 if residuals:
129 if frequency_lines_for_residuals is None:
130 low_freq_indices = np.arange(angular_frequencies.size // 10)
131 high_freq_indices = angular_frequencies.size - \
132 np.arange(angular_frequencies.size // 10) - 1
133 else:
134 low_freq_indices = np.arange(frequency_lines_for_residuals)
135 high_freq_indices = angular_frequencies.size - \
136 np.arange(frequency_lines_for_residuals) - 1
137 solve_indices = np.unique(np.concatenate(
138 (solve_indices, low_freq_indices, high_freq_indices)))
139 kernel_indices = np.concatenate((solve_indices, solve_indices + angular_frequencies.size))
140 # print(kernel_indices)
142 # Set up the kernel to solve the least squares residue problem
143 denominator = ((angular_natural_frequencies**2 - angular_frequencies**2)**2
144 + (2 * damping_ratios * angular_natural_frequencies * angular_frequencies)**2)
145 kernel_rr = (angular_natural_frequencies**2 - angular_frequencies**2) / denominator
146 kernel_ri = 2 * damping_ratios * angular_natural_frequencies * angular_frequencies / denominator
147 kernel_ir = -2 * damping_ratios * angular_natural_frequencies * angular_frequencies / denominator
148 kernel_ii = (angular_natural_frequencies**2 - angular_frequencies**2) / denominator
149 low_frequency_residual = (-1 / angular_frequencies**2)
150 high_frequency_residual = (np.ones(angular_frequencies.shape))
151 zeros = np.zeros(angular_frequencies.shape)
153 kernel = np.concatenate((np.concatenate((kernel_rr, kernel_ri, low_frequency_residual, zeros, high_frequency_residual, zeros), axis=-1),
154 np.concatenate((kernel_ir, kernel_ii, zeros, low_frequency_residual, zeros, high_frequency_residual), axis=-1)), axis=0)
156 # print(kernel.shape)
158 # Reduce kernel depending on whether or not we want real modes or complex
159 # modes
160 if real_modes:
161 kernel[..., angular_natural_frequencies.size:2 * angular_natural_frequencies.size] = 0
163 # Reduce kernel depending on whether or not we want residuals
164 if not residuals:
165 kernel[..., -4:] = 0
167 # Perform the solution in acceleration space
168 kernel *= np.tile(-angular_frequencies**2, (2, 1))
169 frf_matrix *= (1j * angular_frequencies)**(2 - displacement_derivative)
171 # print(kernel.shape)
173 # Weighting matrix
174 if isinstance(weighting, str):
175 if weighting.lower() == 'uniform':
176 weighting = np.ones(frf_matrix.shape)
177 elif weighting.lower() == 'magnitude':
178 max_frf = np.max(np.abs(frf_matrix), axis=0, keepdims=True)
179 max_frf[max_frf == 0.0] = 1.0
180 weighting = np.abs(frf_matrix) / max_frf
181 min_weighting = np.min(weighting, axis=0, keepdims=True)
182 min_weighting[min_weighting == 0] = 1
183 weighting_ratio = weighting / min_weighting
184 weighting_ratio[weighting_ratio == 0] = 1
185 weighting = np.log10(weighting_ratio)
186 else:
187 raise ValueError(
188 'If weighting is specified as a string, must be "uniform" or "magnitude"')
190 # Now assemble the FRF matrix
191 frf_matrix_to_fit = np.concatenate((frf_matrix.real,
192 frf_matrix.imag), axis=0)
194 # Now perform the weighting
195 weighting = np.tile(weighting, (2, 1))
196 weighted_kernel = weighting.T[..., np.newaxis] * kernel
197 weighted_frf_matrix_to_fit = (weighting * frf_matrix_to_fit).T[..., np.newaxis]
199 # print(weighted_kernel.shape)
200 # print(weighted_frf_matrix_to_fit.shape)
202 if frequency_lines_at_resonance is not None:
203 weighted_kernel = weighted_kernel[:, kernel_indices]
204 weighted_frf_matrix_to_fit = weighted_frf_matrix_to_fit[:, kernel_indices]
206 # now solve
207 residues = (np.linalg.pinv(weighted_kernel) @ weighted_frf_matrix_to_fit).squeeze().T
209 frf_fit_matrix = kernel @ residues
210 frf_fit_matrix = (frf_fit_matrix[:frf_fit_matrix.shape[0] // 2, :]
211 + 1j * frf_fit_matrix[frf_fit_matrix.shape[0] // 2:, :])
212 output_frf = flat_frf.extract_elements(abscissa_indices)
213 output_frf.ordinate = (frf_fit_matrix / (1j * angular_frequencies)
214 ** (2 - displacement_derivative)).T
215 output_frf = output_frf.reshape(experimental_frf.shape)
217 # Extract shape residues and residuals
218 shape_residues = (residues[:angular_natural_frequencies.size]
219 + 1j * residues[angular_natural_frequencies.size:2 * angular_natural_frequencies.size])
220 shape_residues = np.moveaxis(shape_residues.reshape(-1, *experimental_frf.shape), 0, -1)
221 if real_modes:
222 shape_residues = np.real(shape_residues)
224 # Extract residuals
225 residual_frf = flat_frf.extract_elements(abscissa_indices)
226 residual_matrix = kernel[:, -4:] @ residues[-4:]
227 residual_matrix = (residual_matrix[:residual_matrix.shape[0] // 2, :]
228 + 1j * residual_matrix[residual_matrix.shape[0] // 2:, :])
229 residual_frf.ordinate = (residual_matrix / (1j * angular_frequencies)
230 ** (2 - displacement_derivative)).T
231 residual_frf = residual_frf.reshape(experimental_frf.shape)
233 return shape_residues, output_frf, residual_frf
236def compute_shapes(natural_frequencies: np.ndarray,
237 damping_ratios: np.ndarray,
238 coordinates: CoordinateArray,
239 residue_matrix: np.ndarray,
240 shape_selection=ShapeSelection.ALL,
241 participation_factors: np.ndarray = None):
242 abs_coordinates = abs(coordinates)
243 sign_matrix = np.prod(coordinates.sign(), axis=-1)
244 equality_matrix = np.all(abs_coordinates == abs_coordinates[..., 0, np.newaxis], axis=-1)
245 drive_point_indices = np.argmax(equality_matrix, axis=0)
246 residue_scales = np.array([sign_matrix[response_index, reference_index]
247 for reference_index, response_index in enumerate(drive_point_indices)])
248 signed_residue_matrix = residue_matrix * residue_scales[:, np.newaxis]
249 drive_point_residues = np.array([signed_residue_matrix[response_index, reference_index, :]
250 for reference_index, response_index in enumerate(drive_point_indices)])
251 negative_drive_points = np.where(drive_point_residues < 0)
252 if np.isrealobj(residue_matrix) and np.any(drive_point_residues < 0):
253 print('Negative Drive Point Residues Found!')
254 for mode_index, reference_index in np.sort(np.array(negative_drive_points).T[..., ::-1], axis=0):
255 print(' Mode {:} Reference {:}'.format(
256 mode_index + 1, str(coordinates[0, reference_index, -1])))
257 mode_shape_scaling = np.sqrt(np.abs(drive_point_residues) if np.isrealobj(
258 residue_matrix) else drive_point_residues)
259 mode_shape_matrix = signed_residue_matrix / mode_shape_scaling
260 if isinstance(shape_selection, str):
261 if shape_selection.lower() in ['all']:
262 shape_selection = ShapeSelection.ALL
263 elif shape_selection.lower() in ['drive', 'drive point', 'dp']:
264 shape_selection = ShapeSelection.DRIVE_POINT_COEFFICIENT
265 elif shape_selection.lower() in ['part', 'participation', 'participation factor']:
266 shape_selection = ShapeSelection.PARTICIPATION_FACTOR
267 if not shape_selection == ShapeSelection.ALL:
268 if shape_selection == ShapeSelection.DRIVE_POINT_COEFFICIENT:
269 shape_selection_indices = np.argmax(drive_point_residues, axis=0)
270 elif shape_selection == ShapeSelection.PARTICIPATION_FACTOR:
271 shape_selection_indices = np.argmax(np.abs(participation_factors), axis=-1)
272 else:
273 raise ValueError('Invalid Shape Selection Technique')
274 mode_shape_matrix = np.array([mode_shape_matrix[:, reference_index, mode_index]
275 for mode_index, reference_index in enumerate(shape_selection_indices)]).T
276 else:
277 shape_selection_indices = np.arange(mode_shape_matrix.shape[1])[
278 :, np.newaxis] * np.ones(mode_shape_matrix.shape[2], dtype=int)
279 ref_array = np.ndarray
280 shapes = shape_array(coordinate=coordinates[..., 0, 0],
281 shape_matrix=np.moveaxis(mode_shape_matrix, 0, -1),
282 frequency=natural_frequencies, damping=damping_ratios,
283 comment1=coordinates[0, :, -1].string_array()[shape_selection_indices])
284 return shapes, negative_drive_points
287def compute_shapes_multireference(experimental_frf: TransferFunctionArray,
288 natural_frequencies: np.ndarray,
289 damping_ratios: np.ndarray,
290 participation_factors: np.ndarray,
291 real_modes: bool = False,
292 lower_residuals: bool = True,
293 upper_residuals: bool = True,
294 min_frequency: float = None,
295 max_frequency: float = None,
296 displacement_derivative: int = 0,
297 frequency_lines_at_resonance: int = None,
298 frequency_lines_for_residuals: int = None):
299 """
300 Computes mode shapes from multireference datasets.
302 Uses the modal participation factor as a constraint on the mode shapes to
303 solve for the shapes in one pass, rather than solving for residues and
304 subsequently solving for shapes.
306 Parameters
307 ----------
308 experimental_frf : TransferFunctionArray
309 Experimental FRF data to which modes will be fit
310 natural_frequencies : np.ndarray
311 Natural Frequencies (in Hz) at which modes will be fit
312 damping_ratios : np.ndarray
313 Damping Ratios at which modes will be fit
314 participation_factors : np.ndarray
315 Mode participation factors from which the shapes can be computed.
316 Should have shape (n_modes x n_inputs)
317 real_modes : bool, optional
318 Specifies whether to solve for real modes or complex modes (default).
319 lower_residuals : bool, optional
320 Use lower residuals in the FRF fit. The default is True.
321 upper_residuals : bool, optional
322 Use upper residuals in the FRF fit. The default is True.
323 min_frequency : float, optional
324 Minimum frequency to use in the shape fit. The default is the lowest
325 frequency in the experimental FRF.
326 max_frequency : float, optional
327 Maximum frequency to use in the shape fit. The default is the highest
328 frequency in the experimental FRF.
329 displacement_derivative : int, optional
330 Defines the type of data in the FRF based on the number of derivatives
331 from displacement (0 - displacement, 1 - velocity, 2 - acceleration).
332 The default is 0 (displacement).
333 frequency_lines_at_resonance : int, optional
334 Defines the number of frequency lines to look at around the specified
335 natural frequencies for computing residues. If not specified, all
336 frequency lines are used for computing shapes.
337 frequency_lines_for_residuals : int, optional
338 Defines the number of frequency lines at the low and high frequency to
339 use in computing shapes. Only used if frequency_lines_at_resonance is
340 specified. If not specified, the lower 10% and upper 10% of frequency
341 lines will be kept for computing residuals.
343 Raises
344 ------
345 ValueError
346 If the FRF is not 2-dimensional with references on the columns and
347 responses on the rows.
349 Returns
350 -------
351 output_shape : ShapeArray
352 ShapeArray containing the mode shapes of the system
353 frfs_resynthesized : TransferFunctionArray
354 FRFs resynthesized from the fit shapes and residuals
355 residual_frfs : TransferFunctionArray
356 FRFs resynthesized only from the residuals used in the calculation
357 kernel_frfs: TransferFunctionArray
358 FRFs synthesized by the kernel solution without taking into account
359 construction of mode shapes and reciprocity with participation factors
360 """
361 original_coordinates = experimental_frf.coordinate
362 if not experimental_frf.ndim == 2:
363 raise ValueError('FRF must be shaped n_outputs x n_inputs')
364 abs_coordinate = abs(original_coordinates)
365 experimental_frf = experimental_frf[abs_coordinate]
366 # Also need to adjust the participation factors
367 participation_factors = participation_factors*np.sign(original_coordinates[0, :, 1].direction)
368 if real_modes:
369 participation_factors = collapse_complex_to_real(participation_factors)
370 frequencies = experimental_frf[0, 0].abscissa.copy()
371 if min_frequency is None:
372 min_frequency = np.min(frequencies)
373 if max_frequency is None:
374 max_frequency = np.max(frequencies)
375 abscissa_indices = np.ones(frequencies.shape, dtype=bool)
376 abscissa_indices &= (frequencies >= min_frequency)
377 abscissa_indices &= (frequencies <= max_frequency)
378 frequencies = frequencies[abscissa_indices]
379 frf_matrix = experimental_frf.ordinate[..., abscissa_indices].copy()
380 angular_frequencies = 2 * np.pi * frequencies
381 reconstruction_angular_frequencies = frequencies*2*np.pi
382 angular_natural_frequencies = 2 * np.pi * np.array(natural_frequencies).flatten()
383 damping_ratios = np.array(damping_ratios).flatten()
385 # Reduce to the kept frequency lines
386 if frequency_lines_at_resonance is not None:
387 solve_indices = np.argmin(np.abs(angular_natural_frequencies - angular_frequencies[:, np.newaxis]), axis=0)
388 # print(solve_indices)
389 solve_indices = np.unique(
390 solve_indices[:, np.newaxis] + np.arange(frequency_lines_at_resonance) - frequency_lines_at_resonance // 2)
391 solve_indices = solve_indices[(solve_indices >= 0) & (
392 solve_indices < angular_frequencies.size)]
393 # Add the residual indices
394 if lower_residuals:
395 if frequency_lines_for_residuals is None:
396 low_freq_indices = np.arange(angular_frequencies.size // 10)
397 else:
398 low_freq_indices = np.arange(frequency_lines_for_residuals)
399 else:
400 low_freq_indices = np.array([],dtype=int)
401 if upper_residuals:
402 if frequency_lines_for_residuals is None:
403 high_freq_indices = angular_frequencies.size - \
404 np.arange(angular_frequencies.size // 10) - 1
405 else:
406 high_freq_indices = angular_frequencies.size - \
407 np.arange(frequency_lines_for_residuals) - 1
408 else:
409 high_freq_indices = np.array([],dtype=int)
410 solve_indices = np.unique(np.concatenate(
411 (solve_indices, low_freq_indices, high_freq_indices)))
412 print(solve_indices)
413 frf_matrix = frf_matrix[..., solve_indices]
414 angular_frequencies = angular_frequencies[solve_indices]
416 poles = -damping_ratios*angular_natural_frequencies + 1j*np.sqrt(1-damping_ratios**2)*angular_natural_frequencies
418 if real_modes:
419 kernel = generate_kernel_real(
420 angular_frequencies, poles,
421 participation_factors, lower_residuals,
422 upper_residuals, displacement_derivative)
423 if angular_frequencies.size != reconstruction_angular_frequencies.size:
424 reconstruction_kernel = generate_kernel_real(
425 reconstruction_angular_frequencies, poles,
426 participation_factors, lower_residuals,
427 upper_residuals, displacement_derivative)
428 else:
429 reconstruction_kernel = kernel
430 else:
431 kernel = generate_kernel_complex(
432 angular_frequencies, poles, participation_factors, lower_residuals,
433 upper_residuals, displacement_derivative)
434 if angular_frequencies.size != reconstruction_angular_frequencies.size:
435 reconstruction_kernel = generate_kernel_complex(
436 reconstruction_angular_frequencies, poles, participation_factors, lower_residuals,
437 upper_residuals, displacement_derivative)
438 else:
439 reconstruction_kernel = kernel
441 coefficients, (full_reconstruction, residual_reconstruction) = stack_and_lstsq(
442 frf_matrix, kernel,
443 return_reconstruction=True,
444 reconstruction_partitions = [slice(None),
445 slice(poles.size*(1 if real_modes else 2),None)],
446 reconstruction_kernel = reconstruction_kernel)
448 residual_frfs = experimental_frf.copy().extract_elements(abscissa_indices)
449 residual_frfs.ordinate = residual_reconstruction
450 kernel_frfs = experimental_frf.copy().extract_elements(abscissa_indices)
451 kernel_frfs.ordinate = full_reconstruction
453 # Extract the shapes and residues
454 if real_modes:
455 shapes = (coefficients[:poles.size]).T
456 else:
457 shapes = (coefficients[:poles.size] + 1j*coefficients[poles.size:2*poles.size]).T
459 # Now we have to go in and find the scale factor to scale the shapes correctly
460 drive_points = np.where(experimental_frf.response_coordinate == experimental_frf.reference_coordinate)
461 if drive_points[0].size == 0:
462 print('Warning: Drive Points Not Found in Dataset, Shapes are Unscaled.')
463 output_shape = shape_array(abs_coordinate[:, 0, 0], shapes.T,
464 angular_natural_frequencies/(2*np.pi),
465 damping_ratios,
466 1)
467 frfs_resynthesized = kernel_frfs
468 else:
469 shapes_normalized = []
470 drive_residues = shapes[drive_points[0]].T*participation_factors[:,drive_points[1]]
471 for k, drive_residue in enumerate(drive_residues):
472 if real_modes:
473 # Throw away negative drive points
474 bad_indices = drive_residue < 0
475 else:
476 # Throw away non-negative imaginary parts
477 bad_indices_positive = drive_residue.imag > 0
478 # Throw away small values compared to the average value (only considering negative imaginary parts)
479 bad_indices_small = np.abs(drive_residue) < 0.01*np.mean(np.abs(drive_residue[~bad_indices_positive]))
480 # Combine into a single criteria
481 bad_indices = bad_indices_positive | bad_indices_small
482 # Get the good indices that are remaining
483 remaining_indices = np.where(~bad_indices)[0]
484 if len(remaining_indices) == 0:
485 print('Warning: Mode {:} had no valid drive points and is therefore unscaled.'.format(k+1))
486 shapes_normalized.append(shapes[:,k])
487 continue
488 # We will then construct the least squares solution
489 shape_coefficients = shapes[drive_points[0][remaining_indices],k][:,np.newaxis]
490 residue_coefficients = np.sqrt(drive_residue[remaining_indices])[:,np.newaxis]
491 # Before we compute the scale, we need to make sure that we have all of the signs the same way.
492 # This is because the square root can give you +/- root where root**2 = complex number
493 # This will mess up the least squares at it will try to find something between the
494 # two vectors.
495 scale_vector = (residue_coefficients/shape_coefficients).flatten()
496 sign_vector = np.array((scale_vector.real,scale_vector.imag))
497 # Get the signs
498 signs = np.sign(np.dot(sign_vector[:,0],sign_vector))
499 residue_coefficients = residue_coefficients*signs[:,np.newaxis]
500 # Now compute the least-squares solution
501 scale = np.linalg.lstsq(shape_coefficients,residue_coefficients)[0].squeeze()
502 print('Scale for mode {:}: {:}'.format(k+1,scale))
503 shapes_normalized.append(shapes[:,k]*scale)
504 shapes_normalized = np.array(shapes_normalized).T
506 output_shape = shape_array(abs_coordinate[:, 0, 0], shapes_normalized.T,
507 angular_natural_frequencies/(2*np.pi),
508 damping_ratios,
509 1)
511 frfs_resynthesized = (output_shape.compute_frf(
512 frequencies,
513 responses=experimental_frf.response_coordinate[:, 0],
514 references=experimental_frf.reference_coordinate[0, :],
515 displacement_derivative=displacement_derivative)
516 + residual_frfs)
518 frfs_resynthesized = frfs_resynthesized[original_coordinates]
519 residual_frfs = residual_frfs[original_coordinates]
520 kernel_frfs = kernel_frfs[original_coordinates]
522 return output_shape, frfs_resynthesized, residual_frfs, kernel_frfs
524def generate_kernel_complex(omegas, poles, participation_factors,
525 lower_residuals = False, upper_residuals = False,
526 displacement_derivative = 0):
527 """
530 Parameters
531 ----------
532 omegas : np.ndarray
533 The angular frequencies (in radians/s) at which the kernel matrix will
534 be computed. This should be a 1D array with length num_freqs.
535 poles : np.ndarray
536 An array of poles corresponding to the modes of the structure. This
537 should be a 1D array with length num_modes.
538 participation_factors : np.ndarray
539 A 2D array of participation factors corresponding to the reference
540 degrees of freedom. This should have shape (num_modes, num_inputs).
541 lower_residuals : bool, optional
542 If True, construct the kernel matrix such that lower residuals will be
543 computed in the least-squares operation. The default is False.
544 upper_residuals : bool, optional
545 If True, construct the kernel matrix such that upper residuals will be
546 computed in the least-squares operation. The default is False.
547 displacement_derivative : int, optional
548 The derivative of displacement used to construct the frequency response
549 functions. Should be 0 for a receptance (displacement/force) frf, 1
550 for a mobility (velocity/force) frf, or 2 for an accelerance
551 (acceleration/force) frf.
553 Returns
554 -------
555 kernel_matrix : np.ndarray
556 A 3D matrix that represents the kernel that can be inverted to solve
557 for mode shapes. The size of the output array will be num_inputs x
558 num_freq*2 x num_modes*2. The top partition of the num_freq dimension
559 corresponds to the real part of the frf, and the
560 bottom portion corresponds to the imaginary part of the frf. The top
561 partition of the num_modes dimension corresponds to the real part of
562 the mode shape matrix, and the bottom partition corresponds to the
563 imaginary part. If residuals are included, there will be an extra two
564 entries along the num_modes dimension corresponding to real and
565 imaginary parts of the residual matrix for each of the residuals
566 included.
567 """
568 omegas = np.array(omegas)
569 poles = np.array(poles)
570 participation_factors = np.array(participation_factors)
571 if omegas.ndim != 1:
572 raise ValueError('`omegas` should be 1-dimensional')
573 if poles.ndim != 1:
574 raise ValueError('`poles` should be 1-dimensional')
575 if participation_factors.ndim != 2:
576 raise ValueError('`participation_factors` should be 2-dimensional (num_modes x num_inputs)')
577 n_modes,n_inputs = participation_factors.shape
578 if poles.size != n_modes:
579 raise ValueError('number of poles ({:}) is not equal to the number of rows in the participation_factors array ({:})'.format(
580 poles.size,n_modes))
581 # Set up broadcasting
582 # We want the output array to be n_input x n_freq*2 x n_modes*2
583 # So let's adjust the terms so they have the right shapes
584 # We want frequency lines to be the middle dimension
585 omega = omegas[np.newaxis,:,np.newaxis]
586 # We want inputs to be the first dimension and modes the
587 # last dimension
588 participation_factors = participation_factors.T[:,np.newaxis,:]
589 # Split up terms into real and imaginary parts
590 p_r = poles.real
591 p_i = poles.imag
592 l_r = participation_factors.real
593 l_i = participation_factors.imag
595 # Now go through the different derivatives and compute the kernel plus any
596 # residuals
597 if displacement_derivative == 0:
598 kernel = np.array([
599 [(-l_i*omega - l_i*p_i - l_r*p_r)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2)
600 + (l_i*omega - l_i*p_i - l_r*p_r)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2),
601 (l_i*p_r - l_r*omega - l_r*p_i)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2)
602 + (l_i*p_r + l_r*omega - l_r*p_i)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2)],
603 [(-l_i*p_r - l_r*omega + l_r*p_i)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2)
604 + (l_i*p_r - l_r*omega - l_r*p_i)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2),
605 (l_i*omega - l_i*p_i - l_r*p_r)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2)
606 + (l_i*omega + l_i*p_i + l_r*p_r)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2)]])
607 if lower_residuals:
608 kernel_RL = np.concatenate([
609 np.kron(-1/omegas[:,np.newaxis]**2,np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0])),
610 np.kron(-1/omegas[:,np.newaxis]**2,np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1]))],axis=1)
611 if upper_residuals:
612 kernel_RU = np.concatenate([
613 np.kron(np.ones((omegas.size,1)),np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0])),
614 np.kron(np.ones((omegas.size,1)),np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1]))],axis=1)
615 elif displacement_derivative == 1:
616 kernel = np.array([
617 [(-l_i*omega*p_r + l_r*omega**2 + l_r*omega*p_i)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2)
618 + (l_i*omega*p_r + l_r*omega**2 - l_r*omega*p_i)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2),
619 (-l_i*omega**2 - l_i*omega*p_i - l_r*omega*p_r)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2)
620 + (-l_i*omega**2 + l_i*omega*p_i + l_r*omega*p_r)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2)],
621 [(-l_i*omega**2 - l_i*omega*p_i - l_r*omega*p_r)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2)
622 + (l_i*omega**2 - l_i*omega*p_i - l_r*omega*p_r)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2),
623 (l_i*omega*p_r - l_r*omega**2 - l_r*omega*p_i)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2)
624 + (l_i*omega*p_r + l_r*omega**2 - l_r*omega*p_i)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2)]])
625 if lower_residuals:
626 kernel_RL = np.concatenate([
627 np.kron( 1/omegas[:,np.newaxis],np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1])),
628 np.kron(-1/omegas[:,np.newaxis],np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0]))],axis=1)
629 if upper_residuals:
630 kernel_RU = np.concatenate([
631 np.kron(-omegas[:,np.newaxis],np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1])),
632 np.kron( omegas[:,np.newaxis],np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0]))],axis=1)
633 elif displacement_derivative == 2:
634 kernel = np.array([
635 [(-l_i*omega**3 + l_i*omega**2*p_i + l_r*omega**2*p_r)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2)
636 + (l_i*omega**3 + l_i*omega**2*p_i + l_r*omega**2*p_r)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2),
637 (-l_i*omega**2*p_r - l_r*omega**3 + l_r*omega**2*p_i)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2)
638 + (-l_i*omega**2*p_r + l_r*omega**3 + l_r*omega**2*p_i)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2)],
639 [(-l_i*omega**2*p_r + l_r*omega**3 + l_r*omega**2*p_i)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2)
640 + (l_i*omega**2*p_r + l_r*omega**3 - l_r*omega**2*p_i)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2),
641 (-l_i*omega**3 - l_i*omega**2*p_i - l_r*omega**2*p_r)/(omega**2 + 2*omega*p_i + p_i**2 + p_r**2)
642 + (-l_i*omega**3 + l_i*omega**2*p_i + l_r*omega**2*p_r)/(omega**2 - 2*omega*p_i + p_i**2 + p_r**2)]])
643 if lower_residuals:
644 kernel_RL = np.concatenate([
645 np.kron(np.ones((omegas.size,1)),np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0])),
646 np.kron(np.ones((omegas.size,1)),np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1]))],axis=1)
647 if upper_residuals:
648 kernel_RU = np.concatenate([
649 np.kron(-omegas[:,np.newaxis]**2,np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0])),
650 np.kron(-omegas[:,np.newaxis]**2,np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1]))],axis=1)
651 kernel = np.block([[kernel[0,0],kernel[0,1]],
652 [kernel[1,0],kernel[1,1]]])
653 if lower_residuals:
654 kernel = np.concatenate((kernel,kernel_RL),axis=-1)
655 if upper_residuals:
656 kernel = np.concatenate((kernel,kernel_RU),axis=-1)
658 return kernel
660def generate_kernel_real(omegas, poles, participation_factors,
661 lower_residuals = False, upper_residuals = False,
662 displacement_derivative = 0):
663 """
666 Parameters
667 ----------
668 omegas : np.ndarray
669 The angular frequencies (in radians/s) at which the kernel matrix will
670 be computed. This should be a 1D array with length num_freqs.
671 poles : np.ndarray
672 An array of poles corresponding to the modes of the structure. This
673 should be a 1D array with length num_modes.
674 participation_factors : np.ndarray
675 A 2D array of participation factors corresponding to the reference
676 degrees of freedom. This should have shape (num_modes, num_inputs).
677 lower_residuals : bool, optional
678 If True, construct the kernel matrix such that lower residuals will be
679 computed in the least-squares operation. The default is False.
680 upper_residuals : bool, optional
681 If True, construct the kernel matrix such that upper residuals will be
682 computed in the least-squares operation. The default is False.
683 displacement_derivative : int, optional
684 The derivative of displacement used to construct the frequency response
685 functions. Should be 0 for a receptance (displacement/force) frf, 1
686 for a mobility (velocity/force) frf, or 2 for an accelerance
687 (acceleration/force) frf.
689 Returns
690 -------
691 kernel_matrix : np.ndarray
692 A 3D matrix that represents the kernel that can be inverted to solve
693 for mode shapes. The size of the output array will be num_inputs x
694 num_freq*2 x num_modes. The top partition of the num_freq dimension
695 corresponds to the real part of the frf, and the
696 bottom portion corresponds to the imaginary part of the frf.
697 If residuals are included, there will be an extra two
698 entries along the num_modes dimension corresponding to real and
699 imaginary parts of the residual matrix for each of the residuals
700 included.
701 """
702 omegas = np.array(omegas)
703 poles = np.array(poles)
704 participation_factors = np.array(participation_factors)
705 if omegas.ndim != 1:
706 raise ValueError('`omegas` should be 1-dimensional')
707 if poles.ndim != 1:
708 raise ValueError('`poles` should be 1-dimensional')
709 if participation_factors.ndim != 2:
710 raise ValueError('`participation_factors` should be 2-dimensional (num_modes x num_inputs)')
711 n_modes,n_inputs = participation_factors.shape
712 if poles.size != n_modes:
713 raise ValueError('number of poles ({:}) is not equal to the number of rows in the participation_factors array ({:})'.format(
714 poles.size,n_modes))
715 # Set up broadcasting
716 # We want the output array to be n_input x n_freq*2 x n_modes*2
717 # So let's adjust the terms so they have the right shapes
718 # We want frequency lines to be the middle dimension
719 omega = omegas[np.newaxis,:,np.newaxis]
720 # We want inputs to be the first dimension and modes the
721 # last dimension
722 l_r = participation_factors.T[:,np.newaxis,:]
723 # Split up terms into real and imaginary parts
724 omega_r = np.abs(poles)
725 zeta_r = -np.real(poles)/omega_r
727 # Now go through the different derivatives and compute the kernel plus any
728 # residuals
729 if displacement_derivative == 0:
730 kernel = np.array([[(-l_r*omega**2 + l_r*omega_r**2)/
731 (omega**4 + 4*omega**2*omega_r**2*zeta_r**2 - 2*omega**2*omega_r**2 + omega_r**4)],
732 [-2*l_r*omega*omega_r*zeta_r/
733 (omega**4 + 4*omega**2*omega_r**2*zeta_r**2 - 2*omega**2*omega_r**2 + omega_r**4)]])
734 if lower_residuals:
735 kernel_RL = np.concatenate([
736 np.kron(-1/omegas[:,np.newaxis]**2,np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0])),
737 np.kron(-1/omegas[:,np.newaxis]**2,np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1]))],axis=1)
738 if upper_residuals:
739 kernel_RU = np.concatenate([
740 np.kron(np.ones((omegas.size,1)),np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0])),
741 np.kron(np.ones((omegas.size,1)),np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1]))],axis=1)
742 elif displacement_derivative == 1:
743 kernel = np.array([[2*l_r*omega**2*omega_r*zeta_r/
744 (omega**4 + 4*omega**2*omega_r**2*zeta_r**2 - 2*omega**2*omega_r**2 + omega_r**4)],
745 [(-l_r*omega**3 + l_r*omega*omega_r**2)/
746 (omega**4 + 4*omega**2*omega_r**2*zeta_r**2 - 2*omega**2*omega_r**2 + omega_r**4)]])
747 if lower_residuals:
748 kernel_RL = np.concatenate([
749 np.kron( 1/omegas[:,np.newaxis],np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1])),
750 np.kron(-1/omegas[:,np.newaxis],np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0]))],axis=1)
751 if upper_residuals:
752 kernel_RU = np.concatenate([
753 np.kron(-omegas[:,np.newaxis],np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1])),
754 np.kron( omegas[:,np.newaxis],np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0]))],axis=1)
755 elif displacement_derivative == 2:
756 kernel = np.array([[(l_r*omega**4 - l_r*omega**2*omega_r**2)/
757 (omega**4 + 4*omega**2*omega_r**2*zeta_r**2 - 2*omega**2*omega_r**2 + omega_r**4)],
758 [2*l_r*omega**3*omega_r*zeta_r/
759 (omega**4 + 4*omega**2*omega_r**2*zeta_r**2 - 2*omega**2*omega_r**2 + omega_r**4)]])
760 if lower_residuals:
761 kernel_RL = np.concatenate([
762 np.kron(np.ones((omegas.size,1)),np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0])),
763 np.kron(np.ones((omegas.size,1)),np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1]))],axis=1)
764 if upper_residuals:
765 kernel_RU = np.concatenate([
766 np.kron(-omegas[:,np.newaxis]**2,np.kron(np.eye(n_inputs)[:,np.newaxis],[1,0])),
767 np.kron(-omegas[:,np.newaxis]**2,np.kron(np.eye(n_inputs)[:,np.newaxis],[0,1]))],axis=1)
768 kernel = np.block([[kernel[0,0]],
769 [kernel[1,0]]])
770 if lower_residuals:
771 kernel = np.concatenate((kernel,kernel_RL),axis=-1)
772 if upper_residuals:
773 kernel = np.concatenate((kernel,kernel_RU),axis=-1)
775 return kernel
777def stack_and_lstsq(frf_matrix, kernel_matrix, return_reconstruction = False,
778 reconstruction_partitions = None,
779 reconstruction_kernel = None):
780 """
781 Stacks the frf and kernel matrices appropriately,
782 then solves the least-squares problem
784 Parameters
785 ----------
786 frf_matrix : np.ndarray
787 A num_outputs x num_inputs x num_frequencies array representing the
788 frequency response function matrix.
789 kernel_matrix : np.ndarray
790 A num_inputs x 2*num_freq x num_coefficients kernel matrix from generated
791 from generate_kernel_complex or generate_kernel_real functions.
792 return_reconstruction : bool
793 If True, a reconstruction of the frequency response function matrix
794 using the kernel and the computed shape coefficients. Default is False.
795 reconstruction_paritions : list
796 If specified, a list of reconstructions will be returned. For each
797 entry in this list, a frf matrix will be reconstructed using the entry
798 as indices into the coefficient matrix's rows and the kernel matrix's
799 columns. This allows, for example, reconstruction of the frf matrix
800 using only the residual terms or only the shape terms. If not
801 specified when `return_reconstruction` is True, only one frf matrix will
802 be returned using all shape coefficients.
803 reconstruction_kernel : np.ndarray
804 A separate kernel matrix used for reconstruction. If not specified, the
805 kernel matrix used to solve the least-squares problem will be used for
806 the reconstruction.
808 Returns
809 -------
810 coefficients : np.ndarray
811 A num_coefficients x num_outputs array of solution values to the
812 least-squares problem.
813 reconstruction : np.ndarray or list of np.ndarray
814 A frf matrix reconstructed from the kernel and the shape coefficients.
815 If multiple `reconstruction_partitions` are specified in a list, then
816 this return value will also be a list of reconstructions corresponding
817 to those partitions. This will only be returned if
818 `return_reconstruction` is True.
821 """
822 H = frf_matrix.transpose(1,2,0) # input, freq, output
823 H = np.concatenate((H.real,H.imag),axis=1) # Stack real/imag along the freq axis
824 if H.shape[:2] != kernel_matrix.shape[:2]:
825 raise ValueError('`frf_matrix` {:} does not have consistent dimensions with the kernel matrix {:}'.format(
826 H.shape,kernel_matrix.shape))
827 H = H.reshape(-1,H.shape[-1]) # Stack input and freq along the same dimension
828 kernel = kernel_matrix.reshape(-1,kernel_matrix.shape[-1]) # Stack input and freq
829 coefficients = np.linalg.lstsq(kernel,H)[0]
830 if not return_reconstruction:
831 return coefficients
832 if reconstruction_kernel is None:
833 reconstruction_kernel = kernel_matrix
834 if reconstruction_partitions is None:
835 H_reconstructed = reconstruction_kernel@coefficients
836 frf_matrix_reconstructed = H_reconstructed.transpose(2,0,1) # output, input, freq
837 frf_matrix_reconstructed = (frf_matrix_reconstructed[...,:frf_matrix_reconstructed.shape[-1]//2] +
838 1j*frf_matrix_reconstructed[...,frf_matrix_reconstructed.shape[-1]//2:])
839 else:
840 frf_matrix_reconstructed = []
841 for partition in reconstruction_partitions:
842 recon = (reconstruction_kernel[...,partition]@coefficients[partition]).transpose(2,0,1) # output, input, freq
843 recon = recon[...,:recon.shape[-1]//2] + 1j*recon[...,recon.shape[-1]//2:]
844 frf_matrix_reconstructed.append(recon)
845 return coefficients, frf_matrix_reconstructed