Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Airplane Modal Test

This page contains an example demonstrating some usages of SDynPy. In this analysis, we will walk through the entirety of the typical analysis/test/analysis workflow. We will:

  1. Load in the finite element model and use it to select instrumentation locations.

  2. Simulate a modal test on the test article to collect time data

  3. Compute FRFs from the excitation response

  4. Fit modes to the frf data

  5. Compare modal fits to finite element model

  6. Perform finite element expansion using SEREP

  7. Create quicklook reports for the test

Imports

For this project, we will import the following modules, including the SDynPy module.

import numpy as np # Used for numeric calculations
import matplotlib.pyplot as plt # For 2d plotting
import sdynpy as sdpy # Used for structural dynamics features

Default Plotting Options

Since we will be plotting a lot of shapes, we will set up some options to use for all geometry plots. See the documentation for sdpy.Geometry.plot for these options. We will create a dictionary of these options so they can be passed into the various plotting functions using the **kwargs syntax, or into the various plotting functions that accept a plot_kwargs argument.

plot_options = {'node_size':0,'line_width':1,'show_edges':False,
                'view_up':[0,1,0]}

Load the Finite Element Model

For complex modal tests, it can be non-trival to select a good set of sensors. Additionally, for test articles with internal geometry, sensor locations may not be accessible once the unit is built up. Therefore, selecting a good initial sensor set can be crucial to the success of a given test. For this reason, many tests begin by using a finite element model to select a set of degrees of freedom. We will follow this path in this example.

Many analyses performed at Sandia National Laboratories use the Exodus file format to store output data. We can load in an Exodus file into SDynPy using the sdpy.Exodus class. Additionally, we can reduce the size of the finite element model by reducing it to just the outer surfaces using sdpy.Exodus.reduce_to_surfaces, which returns a sdpy.ExodusInMemory object. This is a resonable step to take, as we will not be able to put any sensors on the interior of material volumes.

# Define the filename of our finite element model
filename = 'airplane-out.exo'

# Load the finite element model and reduce it to just exterior surfaces
exo = sdpy.Exodus(filename)
fexo = exo.reduce_to_surfaces()
Model Download

Due to the size of the original model (>200 MB), it cannot be hosted on this website. Instead, the reduced model (<15 MB) will be hosted, and users can download the reduced model to resume the workflow in the next steps.

Download the file airplane-reduced.exo and rename it to airplane-reduced.exo. Then, read the file using the following code:

filename = 'airplane-reduced.exo'
fexo = sdpy.Exodus(filename).load_into_memory()

Convert Finite Element Model to SDynPy Objects

At this point, we would like to convert our finite element model into SDynPy objects to make it easier to work with. We will create both a Geometry as well as Shapes.

Creating a Geometry from an Exodus file

Our goal is to create a test geometry. We will note that, while finite element models are often defined exclusively in a single, global coordinate system, we often cannot place sensors in the global coordinate system if, for example, there are surfaces oblique to the principal directions of the part. We often place sensors directly on the surfaces, so the sensor coordinate system will generally be oriented such that it is aligned with the local surface normal of the location that the sensor is placed.

We will therefore create local coordinate systems to use for sensor selection. To do this, we use the local=True argument in the sdpy.geometry.from_exodus function. We will specify a preferred direction in the nose-wise direction preferred_local_orientation=[0,0,1]. The secondary preferred direction will be “up” secondary_preferred_local_orientation=[0,1,0].

geometry = sdpy.geometry.from_exodus(
    fexo,local=True,
    preferred_local_orientation=[0,0,1],
    secondary_preferred_local_orientation=[0,1,0])

Exploring the Geometry object

Take a moment here to explore the Geometry object. If we simply type geometry into the IPython console after running the previous command, we get out the representation of the Geometry object (truncated here to save space)

geometry
Node Index, ID, X, Y, Z, DefCS, DisCS (0,), 1, 0.496, -0.062, -7.000, 20497, 1 (1,), 2, 0.500, 0.000, -7.000, 20497, 2 (2,), 3, -0.496, -0.062, -7.000, 20497, 3 (3,), 4, 0.429, -0.256, -7.000, 20497, 4 (4,), 5, 0.290, -0.407, -7.000, 20497, 5 (5,), 6, 0.103, -0.489, -7.000, 20497, 6 (6,), 7, -0.103, -0.489, -7.000, 20497, 7 (7,), 8, -0.290, -0.407, -7.000, 20497, 8 (8,), 9, -0.429, -0.256, -7.000, 20497, 9 (9,), 10, -0.496, 0.063, -7.000, 20497, 10 (10,), 11, -0.500, 0.021, -7.000, 20497, 11 (11,), 12, -0.500, -0.021, -7.000, 20497, 12 (12,), 13, -0.062, 0.496, -7.000, 20497, 13 (13,), 14, -0.422, 0.268, -7.000, 20497, 14 (14,), 15, -0.268, 0.422, -7.000, 20497, 15 (15,), 16, 0.063, 0.496, -7.000, 20497, 16 (16,), 17, 0.000, 0.500, -7.000, 20497, 17 (17,), 18, 0.496, 0.062, -7.000, 20497, 18 (18,), 19, 0.268, 0.422, -7.000, 20497, 19 (19,), 20, 0.422, 0.268, -7.000, 20497, 20 (20,), 21, 0.389, -0.022, -7.000, 20497, 21 (21,), 22, 0.410, -0.083, -7.000, 20497, 22 (22,), 23, 0.345, -0.162, -7.000, 20497, 23 (23,), 24, 0.233, -0.241, -7.000, 20497, 24 (24,), 25, 0.087, -0.275, -7.000, 20497, 25 (25,), 26, -0.073, -0.258, -7.000, 20497, 26 (26,), 27, -0.228, -0.191, -7.000, 20497, 27 (27,), 28, -0.360, -0.124, -7.000, 20497, 28 (28,), 29, -0.363, -0.021, -7.000, 20497, 29 (29,), 30, -0.189, 0.267, -7.000, 20497, 30 (30,), 31, -0.072, 0.363, -7.000, 20497, 31 (31,), 32, -0.002, 0.408, -7.000, 20497, 32 (32,), 33, 0.066, 0.364, -7.000, 20497, 33 (33,), 34, 0.179, 0.280, -7.000, 20497, 34 (34,), 35, 0.285, 0.174, -7.000, 20497, 35 (35,), 36, 0.357, 0.060, -7.000, 20497, 36 (36,), 37, -0.205, -0.000, -7.000, 20497, 37 (37,), 38, -0.307, 0.146, -7.000, 20497, 38 (38,), 39, -0.004, 0.261, -7.000, 20497, 39 (39,), 40, 0.093, 0.169, -7.000, 20497, 40 (40,), 41, 0.188, 0.084, -7.000, 20497, 41 (41,), 42, 0.260, 0.005, -7.000, 20497, 42 (42,), 43, 0.300, -0.077, -7.000, 20497, 43 (43,), 44, 0.205, -0.124, -7.000, 20497, 44 (44,), 45, 0.089, -0.123, -7.000, 20497, 45 (45,), 46, -0.044, -0.083, -7.000, 20497, 46 (46,), 47, -0.097, 0.146, -7.000, 20497, 47 (47,), 48, 0.014, 0.055, -7.000, 20497, 48 (48,), 49, 0.119, -0.006, -7.000, 20497, 49 (49,), 50, 0.194, -0.042, -7.000, 20497, 50 (50,), 51, -0.451, 0.017, -7.000, 20497, 51 (51,), 52, -0.439, -0.023, -7.000, 20497, 52 (52,), 53, -0.414, 0.052, -7.000, 20497, 53 (53,), 54, -0.432, -0.070, -7.000, 20497, 54 (54,), 55, -0.496, -0.062, -6.000, 20497, 55 (55,), 56, -0.496, -0.062, -6.938, 20497, 56 (56,), 57, -0.496, -0.062, -6.875, 20497, 57 (57,), 58, -0.496, -0.062, -6.812, 20497, 58 (58,), 59, -0.496, -0.062, -6.750, 20497, 59 (59,), 60, -0.496, -0.062, -6.688, 20497, 60 (60,), 61, -0.496, -0.062, -6.625, 20497, 61 (61,), 62, -0.496, -0.062, -6.562, 20497, 62 (62,), 63, -0.496, -0.062, -6.500, 20497, 63 (63,), 64, -0.496, -0.062, -6.438, 20497, 64 (64,), 65, -0.496, -0.062, -6.375, 20497, 65 (65,), 66, -0.496, -0.062, -6.312, 20497, 66 (66,), 67, -0.496, -0.062, -6.250, 20497, 67 (67,), 68, -0.496, -0.062, -6.188, 20497, 68 (68,), 69, -0.496, -0.062, -6.125, 20497, 69 (69,), 70, -0.496, -0.062, -6.062, 20497, 70 (70,), 71, 0.496, -0.062, -6.000, 20497, 71 (71,), 72, 0.496, -0.062, -6.062, 20497, 72 (72,), 73, 0.496, -0.062, -6.125, 20497, 73 (73,), 74, 0.496, -0.062, -6.188, 20497, 74 (74,), 75, 0.496, -0.062, -6.250, 20497, 75 (75,), 76, 0.496, -0.062, -6.312, 20497, 76 (76,), 77, 0.496, -0.062, -6.375, 20497, 77 (77,), 78, 0.496, -0.062, -6.438, 20497, 78 (78,), 79, 0.496, -0.062, -6.500, 20497, 79 (79,), 80, 0.496, -0.062, -6.562, 20497, 80 (80,), 81, 0.496, -0.062, -6.625, 20497, 81 (81,), 82, 0.496, -0.062, -6.688, 20497, 82 (82,), 83, 0.496, -0.062, -6.750, 20497, 83 (83,), 84, 0.496, -0.062, -6.812, 20497, 84 (84,), 85, 0.496, -0.062, -6.875, 20497, 85 (85,), 86, 0.496, -0.062, -6.938, 20497, 86 (86,), 87, -0.429, -0.256, -6.000, 20497, 87 (87,), 88, -0.290, -0.407, -6.000, 20497, 88 (88,), 89, -0.103, -0.489, -6.000, 20497, 89 (89,), 90, 0.103, -0.489, -6.000, 20497, 90 (90,), 91, 0.290, -0.407, -6.000, 20497, 91 (91,), 92, 0.429, -0.256, -6.000, 20497, 92 (92,), 93, -0.429, -0.256, -6.062, 20497, 93 (93,), 94, -0.429, -0.256, -6.125, 20497, 94 (94,), 95, -0.429, -0.256, -6.188, 20497, 95 (95,), 96, -0.429, -0.256, -6.250, 20497, 96 (96,), 97, -0.429, -0.256, -6.312, 20497, 97 (97,), 98, -0.429, -0.256, -6.375, 20497, 98 (98,), 99, -0.429, -0.256, -6.438, 20497, 99 (99,), 100, -0.429, -0.256, -6.500, 20497, 100 . . . Coordinate_system Index, ID, Name, Color, Type (0,), 1, Node 1 Disp, 1, Cartesian (1,), 2, Node 2 Disp, 1, Cartesian (2,), 3, Node 3 Disp, 1, Cartesian (3,), 4, Node 4 Disp, 1, Cartesian (4,), 5, Node 5 Disp, 1, Cartesian (5,), 6, Node 6 Disp, 1, Cartesian (6,), 7, Node 7 Disp, 1, Cartesian (7,), 8, Node 8 Disp, 1, Cartesian (8,), 9, Node 9 Disp, 1, Cartesian (9,), 10, Node 10 Disp, 1, Cartesian (10,), 11, Node 11 Disp, 1, Cartesian (11,), 12, Node 12 Disp, 1, Cartesian (12,), 13, Node 13 Disp, 1, Cartesian (13,), 14, Node 14 Disp, 1, Cartesian (14,), 15, Node 15 Disp, 1, Cartesian (15,), 16, Node 16 Disp, 1, Cartesian (16,), 17, Node 17 Disp, 1, Cartesian (17,), 18, Node 18 Disp, 1, Cartesian (18,), 19, Node 19 Disp, 1, Cartesian (19,), 20, Node 20 Disp, 1, Cartesian (20,), 21, Node 21 Disp, 1, Cartesian (21,), 22, Node 22 Disp, 1, Cartesian (22,), 23, Node 23 Disp, 1, Cartesian (23,), 24, Node 24 Disp, 1, Cartesian (24,), 25, Node 25 Disp, 1, Cartesian (25,), 26, Node 26 Disp, 1, Cartesian (26,), 27, Node 27 Disp, 1, Cartesian (27,), 28, Node 28 Disp, 1, Cartesian (28,), 29, Node 29 Disp, 1, Cartesian (29,), 30, Node 30 Disp, 1, Cartesian (30,), 31, Node 31 Disp, 1, Cartesian (31,), 32, Node 32 Disp, 1, Cartesian (32,), 33, Node 33 Disp, 1, Cartesian (33,), 34, Node 34 Disp, 1, Cartesian (34,), 35, Node 35 Disp, 1, Cartesian (35,), 36, Node 36 Disp, 1, Cartesian (36,), 37, Node 37 Disp, 1, Cartesian (37,), 38, Node 38 Disp, 1, Cartesian (38,), 39, Node 39 Disp, 1, Cartesian (39,), 40, Node 40 Disp, 1, Cartesian (40,), 41, Node 41 Disp, 1, Cartesian (41,), 42, Node 42 Disp, 1, Cartesian (42,), 43, Node 43 Disp, 1, Cartesian (43,), 44, Node 44 Disp, 1, Cartesian (44,), 45, Node 45 Disp, 1, Cartesian (45,), 46, Node 46 Disp, 1, Cartesian (46,), 47, Node 47 Disp, 1, Cartesian (47,), 48, Node 48 Disp, 1, Cartesian (48,), 49, Node 49 Disp, 1, Cartesian (49,), 50, Node 50 Disp, 1, Cartesian (50,), 51, Node 51 Disp, 1, Cartesian (51,), 52, Node 52 Disp, 1, Cartesian (52,), 53, Node 53 Disp, 1, Cartesian (53,), 54, Node 54 Disp, 1, Cartesian (54,), 55, Node 55 Disp, 1, Cartesian (55,), 56, Node 56 Disp, 1, Cartesian (56,), 57, Node 57 Disp, 1, Cartesian (57,), 58, Node 58 Disp, 1, Cartesian (58,), 59, Node 59 Disp, 1, Cartesian (59,), 60, Node 60 Disp, 1, Cartesian (60,), 61, Node 61 Disp, 1, Cartesian (61,), 62, Node 62 Disp, 1, Cartesian (62,), 63, Node 63 Disp, 1, Cartesian (63,), 64, Node 64 Disp, 1, Cartesian (64,), 65, Node 65 Disp, 1, Cartesian (65,), 66, Node 66 Disp, 1, Cartesian (66,), 67, Node 67 Disp, 1, Cartesian (67,), 68, Node 68 Disp, 1, Cartesian (68,), 69, Node 69 Disp, 1, Cartesian (69,), 70, Node 70 Disp, 1, Cartesian (70,), 71, Node 71 Disp, 1, Cartesian (71,), 72, Node 72 Disp, 1, Cartesian (72,), 73, Node 73 Disp, 1, Cartesian (73,), 74, Node 74 Disp, 1, Cartesian (74,), 75, Node 75 Disp, 1, Cartesian (75,), 76, Node 76 Disp, 1, Cartesian (76,), 77, Node 77 Disp, 1, Cartesian (77,), 78, Node 78 Disp, 1, Cartesian (78,), 79, Node 79 Disp, 1, Cartesian (79,), 80, Node 80 Disp, 1, Cartesian (80,), 81, Node 81 Disp, 1, Cartesian (81,), 82, Node 82 Disp, 1, Cartesian (82,), 83, Node 83 Disp, 1, Cartesian (83,), 84, Node 84 Disp, 1, Cartesian (84,), 85, Node 85 Disp, 1, Cartesian (85,), 86, Node 86 Disp, 1, Cartesian (86,), 87, Node 87 Disp, 1, Cartesian (87,), 88, Node 88 Disp, 1, Cartesian (88,), 89, Node 89 Disp, 1, Cartesian (89,), 90, Node 90 Disp, 1, Cartesian (90,), 91, Node 91 Disp, 1, Cartesian (91,), 92, Node 92 Disp, 1, Cartesian (92,), 93, Node 93 Disp, 1, Cartesian (93,), 94, Node 94 Disp, 1, Cartesian (94,), 95, Node 95 Disp, 1, Cartesian (95,), 96, Node 96 Disp, 1, Cartesian (96,), 97, Node 97 Disp, 1, Cartesian (97,), 98, Node 98 Disp, 1, Cartesian (98,), 99, Node 99 Disp, 1, Cartesian (99,), 100, Node 100 Disp, 1, Cartesian . . . Traceline Index, ID, Description, Color, # Nodes ----------- Empty ------------- Element Index, ID, Type, Color, # Nodes (0,), 1, 94, 1, 4 (1,), 2, 94, 1, 4 (2,), 3, 94, 1, 4 (3,), 4, 94, 1, 4 (4,), 5, 94, 1, 4 (5,), 6, 94, 1, 4 (6,), 7, 94, 1, 4 (7,), 8, 94, 1, 4 (8,), 9, 94, 1, 4 (9,), 10, 94, 1, 4 (10,), 11, 94, 1, 4 (11,), 12, 94, 1, 4 (12,), 13, 94, 1, 4 (13,), 14, 94, 1, 4 (14,), 15, 94, 1, 4 (15,), 16, 94, 1, 4 (16,), 17, 94, 1, 4 (17,), 18, 94, 1, 4 (18,), 19, 94, 1, 4 (19,), 20, 94, 1, 4 (20,), 21, 94, 1, 4 (21,), 22, 94, 1, 4 (22,), 23, 94, 1, 4 (23,), 24, 94, 1, 4 (24,), 25, 94, 1, 4 (25,), 26, 94, 1, 4 (26,), 27, 94, 1, 4 (27,), 28, 94, 1, 4 (28,), 29, 94, 1, 4 (29,), 30, 94, 1, 4 (30,), 31, 94, 1, 4 (31,), 32, 94, 1, 4 (32,), 33, 94, 1, 4 (33,), 34, 94, 1, 4 (34,), 35, 94, 1, 4 (35,), 36, 94, 1, 4 (36,), 37, 94, 1, 4 (37,), 38, 94, 1, 4 (38,), 39, 94, 1, 4 (39,), 40, 94, 1, 4 (40,), 41, 94, 1, 4 (41,), 42, 94, 1, 4 (42,), 43, 94, 1, 4 (43,), 44, 94, 1, 4 (44,), 45, 94, 1, 4 (45,), 46, 94, 1, 4 (46,), 47, 94, 1, 4 (47,), 48, 94, 1, 4 (48,), 49, 94, 1, 4 (49,), 50, 94, 1, 4 (50,), 51, 94, 1, 4 (51,), 52, 94, 1, 4 (52,), 53, 94, 1, 4 (53,), 54, 94, 1, 4 (54,), 55, 94, 1, 4 (55,), 56, 94, 1, 4 (56,), 57, 94, 1, 4 (57,), 58, 94, 1, 4 (58,), 59, 94, 1, 4 (59,), 60, 94, 1, 4 (60,), 61, 94, 1, 4 (61,), 62, 94, 1, 4 (62,), 63, 94, 1, 4 (63,), 64, 94, 1, 4 (64,), 65, 94, 1, 4 (65,), 66, 94, 1, 4 (66,), 67, 94, 1, 4 (67,), 68, 94, 1, 4 (68,), 69, 94, 1, 4 (69,), 70, 94, 1, 4 (70,), 71, 94, 1, 4 (71,), 72, 94, 1, 4 (72,), 73, 94, 1, 4 (73,), 74, 94, 1, 4 (74,), 75, 94, 1, 4 (75,), 76, 94, 1, 4 (76,), 77, 94, 1, 4 (77,), 78, 94, 1, 4 (78,), 79, 94, 1, 4 (79,), 80, 94, 1, 4 (80,), 81, 94, 1, 4 (81,), 82, 94, 1, 4 (82,), 83, 94, 1, 4 (83,), 84, 94, 1, 4 (84,), 85, 94, 1, 4 (85,), 86, 94, 1, 4 (86,), 87, 94, 1, 4 (87,), 88, 94, 1, 4 (88,), 89, 94, 1, 4 (89,), 90, 94, 1, 4 (90,), 91, 94, 1, 4 (91,), 92, 94, 1, 4 (92,), 93, 94, 1, 4 (93,), 94, 94, 1, 4 (94,), 95, 94, 1, 4 (95,), 96, 94, 1, 4 (96,), 97, 94, 1, 4 (97,), 98, 94, 1, 4 (98,), 99, 94, 1, 4 (99,), 100, 94, 1, 4 . . .

We see there are a large number of nodes, coordinate systems, and elements, but no tracelines. We can access these data by accessing the Geometry attributes: geometry.node, geometry.coordinate_system, geometry.element, and geometry.traceline.

Nodes

We will start with the NodeArray object revealed by geometry.node. The NodeArray class is a subclass of SdynpyArray, which is itself a subclass of NumPy’s ndarray. All subclasses of SdynpyArray can therefore take advantage of NumPy functions such as intersect1d, unique, or concatenate and also handle indexing and broadcasting identically to the NumPy ndarray.

Subclasses of SdynpyArray store their data internally as a structured array variant of the ndarray. However, as an alternative to accessing the field data using the syntax array['fieldname'], SdynpyArray allows accessing the fields as if they were attributes using the syntax array.fieldname. Many integrated development environments will not recognize these attributes so all SdynpyArray subclasses have a fields attribute that lists the fields stored in the array that can be accessed.

Returning to the geometry.node, we can identify the fields in the object using the command

geometry.node.fields
('id', 'coordinate', 'color', 'def_cs', 'disp_cs')

Here we see the five fields of the NodeArray object. We can obtain even more information about the shape and type of each of these fields using the dtype attribute, which is inherited from NumPy’s ndarray.

geometry.node.dtype
dtype([('id', '<u8'), ('coordinate', '<f8', (3,)), ('color', '<u2'), ('def_cs', '<u8'), ('disp_cs', '<u8')])

Here we see that the geometry.node.id array, which contains the node ID number, is a 8-byte (64-bit) unsigned integer. The geometry.node.disp_cs and geometry.node.def_cs arrays, which contain references to the coordinate system in which the node is defined and in which the node displaces, respectively, are also this data type. The geometry.node.color array, while still an unsigned integer, is only 2 bytes, or 16 bits. Finally, the geometry.node.coordinate, which contains the 3D position of the node as defined in the geometry.node.def_cs, consists 8-byte (64-bit) floating-point data, and also has a shape of (3,), which signifies there are three values of the coordinate for each entry in the geometry.node array. These extra dimensions of the field arrays are appended at the end of dimension of the SdynpyArray subclass. For example, if we compare the shape of the geometry.node array to the geometry.node.coordinate array, we will see that the shapes are identical except for the appending of the length-3 extra dimension on the latter array. Here the shape attribute is also an attribute inherited from NumPy’s ndarray.

geometry.node.shape
(12686,)
geometry.node.coordinate.shape
(12686, 3)

We see that the shape of our geometry.node array is 12686, meaning the geometry we are examining has that many nodes. We then see that the shape of our geometry.node.coordinate array is 12686 x 3, showing that there are three coordinate values for each node.

Coordinate Systems

Coordinate systems in the Geometry object are stored in a CoordinateSystemArray object that can be accessed by geometry.coordinate_system. We will again explore the fields of the CoordinateSystemArray using the dtype.

geometry.coordinate_system.dtype
dtype([('id', '<u8'), ('name', '<U40'), ('color', '<u2'), ('cs_type', '<u2'), ('matrix', '<f8', (4, 3))])

We now see some new types of fields. We still have id and color, which are consistent with the NodeArray object we previously explored. We now have another integer field cs_type which stores the type of coordinate system (0 - cartesian, 1 - cylindrical, 2 - spherical) in a 16-bit unsigned integer field. We also have a name field, which stores a name of the coordinate system in a string of less than 40 characters. Finally, there is the coordinate system’s transformation matrix, stored in the matrix field, which is stored in a 4 x 3 array of 64-bit floating point numbers. Again, recall the shape of the fields are appended to the shape of the base object, so comparing the shape of the CoordinateSystemArray to the shape of its matrix field, we will see that the latter has 2 extra dimensions of length 4 and 3.

geometry.coordinate_system.shape
(12687,)
geometry.coordinate_system.matrix.shape
(12687, 4, 3)

Elements

Elements in the Geometry are stored in an ElementArray object, which can be accessed using the geometry.element attribute. The fields of this object are

geometry.element.dtype
dtype([('id', '<u8'), ('type', 'u1'), ('color', '<u2'), ('connectivity', 'O')])

Like NodeArray and CoordinateSystemArray objects, the ElementArray object also has id and color fields. Each element also has a type field, which is an 8-bit unsigned integer representing the element type as defined by the universal file format dataset 2412. Finally, the element connectivity field is stored as an object array, where each entry in the element array is a NumPy ndarray with length equal to the number of nodes in the element. This construction is necessary as each element might have a different number of nodes, so a single array of fixed size is not possible.

Tracelines

The final visualization tool in the Geometry object is the TracelineArray, which represents a line connecting nodes in the geometry. The fields of the TracelineArray object are

geometry.traceline.dtype
dtype([('id', '<u8'), ('color', '<u2'), ('description', '<U40'), ('connectivity', 'O')])

Similarly to the other geometry objects, TracelineArray objects have id and color, and like to the ElementArray object, it has a connectivity array that specifies the node IDs to connect with the line. The description field stores a name or description of each item in the TracelineArray as a string with less than 40 characters.

Visualizing Degrees of Freedom on a Geometry

Now that we have an understanding of our Geometry object, we would like to visualize it. In particular, we would like to visualize the local coordinate systems in the geometry to verify that they were created correctly.

To do this, we will create a CoordinateArray object, which contains a set of nodes and directions. Since we would like to generate a coordinate system triad for each node, we will use the helper function from_nodelist which creates degrees of freedom for all directions for each node passed in as an argument. Here we will use the entire list of node ID numbers from our geometry.

coordinates = sdpy.coordinate.from_nodelist(geometry.node.id)

Coordinates

Here again is a good place to explore what makes up a CoordinateArray object, which we have just assigned to the variable coordinates. We can examine the data type of the CoordinateArray to see that it contains fields for a 64-bit unsigned integer as the node field and an 8-bit signed integer for the direction field.

coordinates.dtype
dtype([('node', '<u8'), ('direction', 'i1')])

CoordinateArray objects store the direction as an integer where rotations are encoded:

  • X+=1X+ = 1

  • X=1X- = -1

  • Y+=2Y+ = 2

  • Y=2Y- = -2

  • Z+=3Z+ = 3

  • Z=3Z- = -3

  • RX+=4RX+ = 4

  • RX=4RX- = -4

  • RY+=5RY+ = 5

  • RY=5RY- = -5

  • RZ+=6RZ+ = 6

  • RZ=6RZ- = -6

  • (empty) =0= 0

When we want to read CoordinateArray objects, the integer directions are typically transformed into the more readable direction strings shown in the first column of the above table. For example, if we type coordinates into the console, the representation of the object displays the string array version of the coordinates.

coordinates
coordinate_array(string_array= array(['1X+', '1Y+', '1Z+', ..., '20496X+', '20496Y+', '20496Z+'], shape=(38058,), dtype='<U7'))

From the above, we can see that the coordinate variable we just created contains a degree of freedom for each of the positive X, Y, Z directions at each node.

Many SDynPy objects allow indexing with a CoordinateArray object to automatically handle the bookkeeping aspect of selecting the right data for each coordinate.

Plotting Coordinates

At this point, we would like to plot our coordinates on top of our geometry. For this we use the plot_coordinate method of the Geometry object.

plotter = geometry.plot_coordinate(
    coordinates,arrow_scale=0.005,
    plot_kwargs=plot_options)

Note that due to the density of the mesh, we had to make the arrow_scale much smaller than the default. Here also note that we passed the previously defined plot_options into the plot_kwargs argument. This method should produce a graphic similar to the one shown below.

Airplane Geometry with Coordinate Systems Shown

If we zoom into the coordinate systems on the figure, we see that the Z+ (blue) direction is always oriented perpendicular to the surface, and the X+ (red) direction is always oriented towards the direction specified by the preferred_local_orientation argument to the sdpy.geometry.from_exodus function, which was the global Z+ direction.

Airplane geometry with coordinate systems close-up

Creating Shapes from an Exodus File

We just finished extracting geometry with local coordinate systems. We will now extract shapes from the sdpy.ExodusInMemory object. To do this, we will use the sdpy.shape.from_exodus function. Note that shapes gathered from this function will be in the global coordinate system (as defined in the model), so we will assign the variable a name that denotes that these are global shapes.

shapes_global = sdpy.shape.from_exodus(fexo)

Shapes

At this point, it is useful to explore briefly the ShapeArray object in the IPython console. The data type of the object is:

shapes_global.dtype
dtype([('frequency', '<f8'), ('damping', '<f8'), ('coordinate', [('node', '<u8'), ('direction', 'i1')], (38058,)), ('shape_matrix', '<f8', (38058,)), ('modal_mass', '<f8'), ('comment1', '<U80'), ('comment2', '<U80'), ('comment3', '<U80'), ('comment4', '<U80'), ('comment5', '<U80')])

The data type of ShapeArray objects can change depending on what type of shape and how many degrees of freedom are in the shape. frequency and damping fields are stored as 64-bit floating point numbers with one value per entry in the ShapeArray. modal_mass is also stored in the present ShapeArray, but if the shape is complex, then the modal mass might also be complex. The shape_matrix field holds the underlying shape data. It has one entry for every degree of freedom in the shape, and is represented by a floating point number for normal modes or a complex number for complex modes. Similarly, the coordinate field identifies which degree of freedom belongs to which entry in the shape_matrix field. The coordinate field stores data as CoordinateArray objects, and thus has the same data type as CoordinateArray. Finally, there are five fields available for comments, which store string data up to 80 characters which can be used to store any data the user feels is relevant to the analysis.

One thing to note is that the shape_matrix field, due to the dimension of the field being appended at the end of the array, will be transposed from the typical representation of a mode shape matrix (degrees of freedom as rows and mode indices as columns). The shape_matrix field will instead have the shape of the ShapeArray object itself as its first dimensions, and then the size of the coordinate field as its last dimension.

shapes_global.shape
(43,)
shapes_global.shape_matrix.shape
(43, 38058)

Correcting Negative Frequencies

One thing to note is that sometimes finite element codes that solve for zero frequency modes can have slight errors that result in small negative natural frequencies. These can, however, cause issues due to stiffness instabilities if, for example, the system is integrated over time. It is easy to set all of these negative natural frequencies to positive natural frequencies.

shapes_global.frequency[shapes_global.frequency < 0] = 0

Note carefully here the order of operations in the above statement. We have a ShapeArray object containing our shape data. We then access the frequency field of that ShapeArray object and index that array to select only those frequencies that are less than zero and set them to zero. Note the subtle difference to the following line of code.

shapes_global[shapes_global.frequency < 0].frequency = 0

In the above line of code, we first index our ShapeArray object to access only the shapes with frequency less than zero, then access the frequency field of just those shapes and assign to zero. This seems like it should be the identical operation, correct? Indeed, both operations return the same negative frequency values.

However, experienced users of NumPy should readily identify the subtle difference between the above two operations. In the second, the shapes_global ShapeArray is indexed by a logical array (a NumPy ndarray consisting of True and False, True where the frequency field is less than 0). This will trigger NumPy’s advanced indexing schemes, which always return a copy of rather than a view into the underlying data. Therefore, in the second case, what we are looking at is the frequency field of a copy of our shapes_global variable rather than the raw data. Similarly, assigning directly to this copy will not change the data of the original shapes_global variable.

Transforming Shape Coordinate Systems

As mentioned above, the shapes that come from the sdpy.shape.from_exodus function are defined in the global cordinate system. We can see that if we plot the shapes on the geometry we loaded previously using the plot_shape method of the Geometry object, the shapes do not look right, due to plotting shapes defined using a global coordinate system onto a geometry defined using local coordinate systems. For example, the shape below should be a rigid body mode of the system, but instead appears to be a torsional mode of the system.

plotter = geometry.plot_shape(shapes_global,plot_options)
Plotting a global shape on a local geometry results in incorrect motions.

What we need to do is transform the global shapes into the local shapes defined on the geometry. We can do this using the transform_coordinate_system method of the ShapeArray object. This method takes two arguments; the first is a Geometry object that defines the coordinate systems that we are transforming “from” and the second is a Geometry object that defines the coordinate systems that we are transforming “to”. We already have the “to” geometry in the form of the local geometry we loaded from the Exodus file. We therefore only need to create a global geometry corresponding to the global coordinate system that our shapes are currently defined in to transform “from”. This is easily done using the sdpy.geometry.from_exodus function while not passing in the arguments that specify the local coordinate systems should be created. Plotting the same coordinates CoordinateArray on this new geometry will verify that all node coordinate systems are aligned with the global coordinate system. We can also plot shapes_global with geometry_global to verify that the shapes look right.

# Get the global geometry
geometry_global = sdpy.geometry.from_exodus(fexo)

# Plot the coordinates on the global geometry
plotter = geometry_global.plot_coordinate(coordinates,arrow_scale=0.005,
                                            plot_kwargs=plot_options)
Airplane geometry with global coordinate systems shown close-up
# Plot global shapes with global geometry
plotter = geometry_global.plot_shape(shapes_global,plot_options)
Plotting a global shape on a global geometry results in correct motions.

At this point, we have the requirements to transform coordinate systems. We can now plot the local shapes with the local geometry to verify that the shapes are indeed correct. These shapes should look identical to the above figure even though the underlying data is represented in a different coordinate system.

# Transform shapes to local coordinate systems from global coordinate systems
shapes = shapes_global.transform_coordinate_system(geometry_global, geometry)

# Plot the local shapes on the local geometry to verify
plotter = geometry.plot_shape(shapes,plot_options)
Plotting a local shape on a local geometry results in correct motions.

Optimizing Instrumentation for Test

The reason we went through all the trouble to create shapes in local coordinate systems is that these are the coordinate systems in which test instrumentation is generally placed. We will now want to downselect a set of sensors to use in the actual test from a candidate set of instruments.

For this example, we will use the effective independence algorithm implemented in sdpy.dof.by_effective_independence. This function starts with a candidate set of degrees of freedom and iteratively throws away the degrees of freedom with the smallest contribution to the effective independence of the system. Because there are currently a lot of nodes in our geometry (12,686), it will take a long time to consider each one of these locations as a potential sensor in a test. Instead, we will reduce the candidate set by overlaying a grid of points over the test geometry with a specified spacing using the node_by_global_grid method.

candidate_nodes = geometry.node_by_global_grid(0.25)
candidate_node_ids = candidate_nodes.id

We have reduced our initial candidate set of 12,686 nodes down to 1,217 nodes.

candidate_node_ids.size
1217

We can visualize this initial grid on our model by creating a CoordinateArray object from these nodes using the sdpy.coordinate_array function and broadcasting the node identification numbers with the three directions. We can then plot these degrees of freedom on our geometry.

candidate_dofs = sdpy.coordinate_array(candidate_node_ids,['X+','Y+','Z+'],force_broadcast=True)

plotter = geometry.plot_coordinate(
    candidate_dofs, arrow_scale=0.01, plot_kwargs=plot_options)
Candidate Sensor Locations Selected via a Grid

Now that we have selected our candidate degrees of freedom, we need to select our target shapes. Here we will consider the bandwidth of the test plus a bit more to capture some effects of out-of-band modes. We are interested in data up to 200 Hz for this test, so we will consider shapes up to 300 Hz for this instrumentation selection. We can use logical indexing to perform this frequency reduction, keeping only shapes where the frequency field is less than the desired bandwidth.

# Define the shape bandwidth
shape_bandwidth = 300

# Keep only shapes in the target set that have frequency less than the bandwidth
target_shapes = shapes[shapes.frequency < shape_bandwidth]

At this point, we will need to create a ShapeArray object containing only the candidate nodes, which will be the starting point of our optimization. We can do this easily using ShapeArray.reduce and passing the array of node identification numbers to keep.

target_shapes = target_shapes.reduce(candidate_node_ids)

We can now run the sensor selection algorithm using the effective independence objective function. While the sensor selection schemes are contained in sdpy.dof, these capabilities can be accessed more easily through the ShapeArray.optimize_degrees_of_freedom method. We give it our sensors_to_keep and shape_matrix, as well as give the optional flag to return_efi so we can visualize the effective independence of the shape matrix as degrees of freedom are removed. We can plot this function, as well as the kept degrees of freedom by indexing the oridinal set of degrees of freedom with the keep_indices returned from the by_effective_independence function. Intuitively, the sensors seem well-placed, with many positioned at the boundaries of the wing and tail, as well as a few on the nose and fuselage.

sensors_to_keep = 30

test_shapes = target_shapes.optimize_degrees_of_freedom(
    sensors_to_keep,
    group_by_node=True,  # Keep 30 nodes (triaxes) rather than 30 degrees of freedom (uniaxes)
    method='ei' # 'ei' for effective independence, 'cond' for condition number
    )

# Extract and visualize the degrees of freedom that are kept.
keep_dofs = np.unique(test_shapes.coordinate)
plotter = geometry.plot_coordinate(keep_dofs,arrow_scale=0.02,plot_kwargs = plot_options)
Test Sensors plotted on the Finite Eleemnt Model

Creating a Test Geometry

Because the test sensors are a small subset of the nodes on the original finite element model, it can be useful to add visualization features to the test geometry to help visualize responses from the test. We have already explored elements, which our finite element model has in abundance, so for the test geometry we will use tracelines to aid in visualization. We will use the helper function add_traceline.

The first step in this process is to simply reduce the original geometry down to our kept sensor locations. using the reduce method. This method will discard any coordinate systems, tracelines, or elements that do not entirely consist of the nodes that are kept, which in this case means that the geometry will consist of only nodes and their coordinate systems.

# Create a test geometry and shapes to plot on the test geometry
test_geometry = geometry.reduce(np.unique(keep_dofs.node))
test_geometry
Node Index, ID, X, Y, Z, DefCS, DisCS (0,), 2796, -0.051, 0.497, -2.562, 20497, 2796 (1,), 5248, -0.095, 0.031, 0.000, 20497, 5248 (2,), 6157, -4.970, 0.310, -2.500, 20497, 6157 (3,), 6172, -1.413, -0.001, -2.500, 20497, 6172 (4,), 6214, -3.989, 0.224, -2.500, 20497, 6214 (5,), 6272, -2.438, -0.037, -2.500, 20497, 6272 (6,), 6376, -4.970, 0.310, -4.000, 20497, 6376 (7,), 6392, -3.989, 0.224, -4.000, 20497, 6392 (8,), 6405, -3.192, 0.155, -4.000, 20497, 6405 (9,), 8143, -1.384, -0.129, -4.000, 20497, 8143 (10,), 8160, -2.438, -0.037, -4.000, 20497, 8160 (11,), 11664, 2.438, -0.037, -2.500, 20497, 11664 (12,), 11705, 4.970, 0.310, -2.500, 20497, 11705 (13,), 11722, 3.989, 0.224, -2.500, 20497, 11722 (14,), 11735, 3.192, 0.155, -2.500, 20497, 11735 (15,), 11764, 1.413, -0.001, -2.500, 20497, 11764 (16,), 11892, 2.438, -0.037, -4.000, 20497, 11892 (17,), 11909, 1.384, -0.129, -4.000, 20497, 11909 (18,), 13603, 4.970, 0.310, -4.000, 20497, 13603 (19,), 13647, 3.192, 0.155, -4.000, 20497, 13647 (20,), 13660, 3.989, 0.224, -4.000, 20497, 13660 (21,), 17107, 2.000, -0.062, -6.000, 20497, 17107 (22,), 17563, 2.000, 0.062, -7.000, 20497, 17563 (23,), 17573, 1.123, 0.062, -7.000, 20497, 17573 (24,), 18331, 0.063, 2.000, -6.000, 20497, 18331 (25,), 18416, 0.063, 1.185, -7.000, 20497, 18416 (26,), 18787, -0.062, 2.000, -7.000, 20497, 18787 (27,), 19579, -2.000, -0.062, -6.000, 20497, 19579 (28,), 19651, -2.000, 0.063, -7.000, 20497, 19651 (29,), 19665, -1.123, 0.063, -7.000, 20497, 19665 Coordinate_system Index, ID, Name, Color, Type (0,), 2796, Node 2796 Disp, 1, Cartesian (1,), 5248, Node 5248 Disp, 1, Cartesian (2,), 6157, Node 6157 Disp, 1, Cartesian (3,), 6172, Node 6172 Disp, 1, Cartesian (4,), 6214, Node 6214 Disp, 1, Cartesian (5,), 6272, Node 6272 Disp, 1, Cartesian (6,), 6376, Node 6376 Disp, 1, Cartesian (7,), 6392, Node 6392 Disp, 1, Cartesian (8,), 6405, Node 6405 Disp, 1, Cartesian (9,), 8143, Node 8143 Disp, 1, Cartesian (10,), 8160, Node 8160 Disp, 1, Cartesian (11,), 11664, Node 11664 Disp, 1, Cartesian (12,), 11705, Node 11705 Disp, 1, Cartesian (13,), 11722, Node 11722 Disp, 1, Cartesian (14,), 11735, Node 11735 Disp, 1, Cartesian (15,), 11764, Node 11764 Disp, 1, Cartesian (16,), 11892, Node 11892 Disp, 1, Cartesian (17,), 11909, Node 11909 Disp, 1, Cartesian (18,), 13603, Node 13603 Disp, 1, Cartesian (19,), 13647, Node 13647 Disp, 1, Cartesian (20,), 13660, Node 13660 Disp, 1, Cartesian (21,), 17107, Node 17107 Disp, 1, Cartesian (22,), 17563, Node 17563 Disp, 1, Cartesian (23,), 17573, Node 17573 Disp, 1, Cartesian (24,), 18331, Node 18331 Disp, 1, Cartesian (25,), 18416, Node 18416 Disp, 1, Cartesian (26,), 18787, Node 18787 Disp, 1, Cartesian (27,), 19579, Node 19579 Disp, 1, Cartesian (28,), 19651, Node 19651 Disp, 1, Cartesian (29,), 19665, Node 19665 Disp, 1, Cartesian (30,), 20497, Global, 1, Cartesian Traceline Index, ID, Description, Color, # Nodes ----------- Empty ------------- Element Index, ID, Type, Color, # Nodes ----------- Empty -------------

At this point, we would like to connect the nodes with tracelines. We can plot the kept sensor degrees of freedom on the reduced test_geometry, and specify label_dofs=True to create labels at each of the coordinates. This will allow us to easily see which nodes should be connected. Alternatively, we can simply plot the geometry and label the nodes.

test_geometry.plot(label_nodes=np.unique(keep_dofs.node),**plot_options);
Labelled nodes on the test geometry

With the points labeled, it is easy to see which nodes should be connected using tracelines. When we create tracelines, we will also set the colors, using color=1 (blue) for the fuselage, color=7 (green) for the wings, and color=13 (pink) for the tail. After the tracelines are added, it is much easier to visualize the structure of the airplane.

# Fuselage
test_geometry.add_traceline([5248,2796],color=1)
# Wings
test_geometry.add_traceline([6172,6272,6214,6157,6376,6392,6405,8160,8143,6172],color=7)
test_geometry.add_traceline([11909,11892,13647,13660,13603,11705,11722,11735,11664,11764,11909],color=7)
# Tail
test_geometry.add_traceline([19579,19651,19665,19579],color=13)
test_geometry.add_traceline([17573,17563,17107,17573],color=13)
test_geometry.add_traceline([18787,18416,18331,18787],color=13)

# Now plot the geometry to see the tracelines
test_geometry.plot(label_nodes=np.unique(keep_dofs.node),**plot_options);
Test Geometry with Tracelines

With this new test geometry, we should be able to visualize the reduced test shapes. We should also plot the Modal Assurance Criterion (MAC) matrix with the ShapeArray.plot_mac method to ensure we can distinguish the shapes of interest with our reduced sensor set.

test_shapes.plot_mac(text_size=4);
<Figure size 640x480 with 2 Axes>
plotter = test_geometry.plot_shape(test_shapes, plot_kwargs=plot_options)
Representative elastic shape plotted on the test geometry

Ooops! An Instrumentation Error!

Installing instrumentation is a tedious process that must be well-documented for success of a given test. For large channel-count tests, it is almost inevitable that there will be some kind of sensor or geometry error, so it is useful to develop workflows to verify that sensors are oriented correctly. Here we will purposefully introduce an instrumentation error by setting the displacement coordinate system of one of our sensors to the global coordinate system rather than its correct local coordinate system. We will then investigate a workflow in SDynPy for identifying correcting this error.

# Copy the geometry so we don't overwrite our correct version
test_geometry_error = test_geometry.copy()
# Change the displacement coordinate system of the 5th node to the global
# coordinate system
test_geometry_error.node.disp_cs[4] = test_geometry_error.coordinate_system.id[-1]

Adding Damping

Before we start simulating tests, we will add damping to our test shapes. Our finite element model had no damping prescribed, but all real test articles will have damping. To keep it simple, we will add a blanket 2% damping to all modes in the model.

test_shapes.damping = 0.02

Running a Virtual Experiment: Rigid Body Checkouts

One approach to validating the test geometry and channel table is to perform rigid body checks, where the structure is excited at a frequency well below the elastic modes of the system to elicit a rigid body response. These response shapes can be visualized on the geometry and should appear rigid. If, for example, a sensor is moving out of phase with the rest of the sensors, it is fairly certain there is an instrumentation or bookkeeping error. When looking at acceleration response, these rigid shapes should also have very small imaginary parts compared to their real parts. Finally, a projector can be used to project the measured rigid body shapes through analytic rigid body shapes created from the test geometry. This operation will remove any “non-rigid” components of the measured shapes. By subtracting this “rigidized” version from the measured shapes, a residual can be created where large values in the residual point to non-rigid motions.

R=ΦΦaΦa+Φ\mathbf{R} = \mathbf{\Phi} - \mathbf{\Phi}_a{\mathbf{\Phi}_a}^+\mathbf{\Phi}

Creating a System Object to Integrate

We will now simulate one of these rigid body tests by integrating equations of motion. SDynPy uses System objects to represent dynamical systems.
System objects contain mass, stiffness, and damping matrices. They also can contain a transformation matrix to return to physical coordinates if the internal state degrees of freedom are represented in a reduced space (for example, a mode shape matrix is the transformation between modal mass, stiffness, and damping matrices and the corresponding physical coordinates). System objects also contain a CoordinateArray to aid in bookkeeping.

We can easily create a modal System from our existing shapes by using the ShapeArray.system method. This will return a state matrix in modal space. We can use the System.spy method to visualize the structure of the created system.

# Create a System from the test shapes
test_system = test_shapes.system()
# Look at the structure of the System object
test_system.spy();
<Figure size 1000x300 with 3 Axes>

We can see from the above matrix plot that the internal state is diagonal, consisting of 43 modal degrees of freedom, whereas the transformation matrices (which are the mode shape matrices in this case) are full, consisting of 90 physical degrees of freedom.

Exploring the System Object

Here is a good place to take time to investigate the System object we have just created. We can look at the mass, stiffness, and damping attributes to see those matrices, and verify that they are indeed diagonal for the modal system we have created.

test_system.mass
array([[1., 0., 0., ..., 0., 0., 0.], [0., 1., 0., ..., 0., 0., 0.], [0., 0., 1., ..., 0., 0., 0.], ..., [0., 0., 0., ..., 1., 0., 0.], [0., 0., 0., ..., 0., 1., 0.], [0., 0., 0., ..., 0., 0., 1.]], shape=(43, 43))
test_system.stiffness
array([[ 0. , 0. , 0. , ..., 0. , 0. , 0. ], [ 0. , 0. , 0. , ..., 0. , 0. , 0. ], [ 0. , 0. , 0. , ..., 0. , 0. , 0. ], ..., [ 0. , 0. , 0. , ..., 2868905.99964995, 0. , 0. ], [ 0. , 0. , 0. , ..., 0. , 3210105.88194565, 0. ], [ 0. , 0. , 0. , ..., 0. , 0. , 3289727.48791013]], shape=(43, 43))
test_system.damping
array([[ 0. , 0. , 0. , ..., 0. , 0. , 0. ], [ 0. , 0. , 0. , ..., 0. , 0. , 0. ], [ 0. , 0. , 0. , ..., 0. , 0. , 0. ], ..., [ 0. , 0. , 0. , ..., 67.75138079, 0. , 0. ], [ 0. , 0. , 0. , ..., 0. , 71.66707341, 0. ], [ 0. , 0. , 0. , ..., 0. , 0. , 72.55042371]], shape=(43, 43))

We can also look at the transformation and coordinate to see that the bookkeeping from internal state (modal) degree of freedom to physical degree of freedom is maintained.

test_system.transformation
array([[-2.07869494e-03, -1.48998184e-03, 4.84093796e-03, ..., 1.73749203e-03, -3.09022372e-04, -1.68270938e-05], [ 2.64711408e-04, -1.11413296e-03, -2.61810512e-03, ..., 1.46528917e-04, 6.02431820e-04, -5.76349533e-03], [ 1.36652466e-03, -1.31689605e-04, -4.88540944e-04, ..., 5.49012119e-04, 2.74121905e-03, 2.04985114e-04], ..., [-8.39924861e-03, 5.69749767e-04, 2.09014707e-03, ..., 1.34896916e-02, 3.71546727e-03, 1.47339469e-03], [-3.20599089e-04, -8.22217208e-03, -2.51233710e-03, ..., 2.39905468e-03, -1.48742295e-05, -2.91024169e-05], [-7.34691266e-03, 3.68049092e-04, -4.91393570e-03, ..., 1.31560277e-02, 3.15253196e-03, 1.19728196e-03]], shape=(90, 43))
test_system.coordinate
coordinate_array(string_array= array(['2796X+', '2796Y+', '2796Z+', '5248X+', '5248Y+', '5248Z+', '6157X+', '6157Y+', '6157Z+', '6172X+', '6172Y+', '6172Z+', '6214X+', '6214Y+', '6214Z+', '6272X+', '6272Y+', '6272Z+', '6376X+', '6376Y+', '6376Z+', '6392X+', '6392Y+', '6392Z+', '6405X+', '6405Y+', '6405Z+', '8143X+', '8143Y+', '8143Z+', '8160X+', '8160Y+', '8160Z+', '11664X+', '11664Y+', '11664Z+', '11705X+', '11705Y+', '11705Z+', '11722X+', '11722Y+', '11722Z+', '11735X+', '11735Y+', '11735Z+', '11764X+', '11764Y+', '11764Z+', '11892X+', '11892Y+', '11892Z+', '11909X+', '11909Y+', '11909Z+', '13603X+', '13603Y+', '13603Z+', '13647X+', '13647Y+', '13647Z+', '13660X+', '13660Y+', '13660Z+', '17107X+', '17107Y+', '17107Z+', '17563X+', '17563Y+', '17563Z+', '17573X+', '17573Y+', '17573Z+', '18331X+', '18331Y+', '18331Z+', '18416X+', '18416Y+', '18416Z+', '18787X+', '18787Y+', '18787Z+', '19579X+', '19579Y+', '19579Z+', '19651X+', '19651Y+', '19651Z+', '19665X+', '19665Y+', '19665Z+'], dtype='<U7'))

Setting up the Integration and Forcing Function

We aim integrate our test_system using the System.time_integrate method, but first we need to set up its required sampling parameters. We will specify our test bandwidth, as well as an integration oversample factor to ensure that the integration is performed accurately.
System objects represent Linear, Time-Invariant systems, so the scipy.signal.lsim function is used for integration within the System.time_integrate function, and this typically only requires a factor of 10x the desired sampling rate to achieve reasonable results. We will also define the desired frequency spacing for the test. The other sampling parameters can be derived from these definitions. We will measure 10 frames so averaging can be performed when computing frequency response functions.

# First let's set up our general sampling parameters for our test.
test_bandwidth = 200 # Hz
integration_oversample = 10 #x
sample_rate = test_bandwidth*2*integration_oversample
dt = 1/sample_rate
df = 0.125 # Hz
samples_per_frame = int(sample_rate/df)
rb_frames = 10

We will now create the forcing function for our rigid body test, which will be a sine wave. The first elastic mode of the test article is near 6 Hz, so we choose a frequency that is much lower than that value. We can then use the sdpy.generator.sine function to produce a sine wave with the proper sampling parameters. However, a more straightforward approach is to use the sdpy.TimeHistoryArray.sine_signal method, which automatically constructs a TimeHistoryArray object. The functions in sdpy.generator only produce NumPy arrays which must then be turned into TimeHistoryArray objects.

As we construct these signals, we must also select the degrees of freedom at which the forces will be applied. We will select degrees of freedom that largely point through the airplane’s center of mass to ensure largely translational response.

The sine_signal method accepts arguments such as amplitude, phase, and various comments; however, we will leave these as the default values for now.

# Select a frequency much lower than the first elastic mode
rb_frequency = 0.5 # Hz

# To run the rigid body tests, we select degrees of freedom approximately
# through the CG of the part.
rb_coordinates = sdpy.coordinate_array(string_array=['2796Z+','11722Y+','2796X+'])

# Create the sine signals
sine_signals = sdpy.TimeHistoryArray.sine_signal(dt, samples_per_frame*rb_frames, rb_coordinates, rb_frequency)

# Plot the sine signals
sine_signals.plot(one_axis=False);
<Figure size 640x480 with 3 Axes>

Running the Integration to Generate Synthetic Test Data

We will now loop through each of our excitation degrees of freedom and integrate the System’s response to the force located at that position using System.time_integrate. This will give us responses as TimeHistoryArray objects. We will then truncate off the first second of the response in order to remove the startup transients from the data using TimeHistoryArray.extract_elements_by_abscissa. We will then pass the truncated data into the sdpy.TransferFunctionArray.from_time_data function to produce frequency response functions as a TransferFunctionArray object. Once the frequency response functions are computed, we can extract the deflection shapes to get the deflection shape using to_shape_array, which will produce a ShapeArray object. We append each shape to a running list, and then concatenate all the shapes into one ShapeArray object, which we can plot on our erroneous geometry.

When performing time integration, we must specify which degrees of freedom we would like responses to be generated at. Additionally, we need to specify what type of response (displacement, velocity, or acceleration), we would like. We do this using a dictionary syntax where the keys are the displacement derivative (e.g. 2 for acceleration, which is the second derivative of displacement). We would like all acceleration responses, which means we should populate the response dictionary key of 2 and the value as the degrees of freedom we would like to keep.

# Set up our desired responses
response_dofs = {2: # Acceleration
                 keep_dofs # All optimized degrees of freedom
                }
# Create a list to hold our shapes
rb_shapes = []
# Loop through each of our excitation signals, which each has a different location
for reference_signal in sine_signals:
    # Perform time integration to get the responses to our sine wave
    print('Integrating Rigid Body Excitation at {:}'.format(str(reference_signal.coordinate)))
    responses = test_system.time_integrate(
        forces = reference_signal,
        responses = response_dofs
        )
    # Plot the responses and references
    fig,ax = plt.subplots(2,1,sharex=True,
                            num='Rigid Body Test {:}'.format(str(reference_signal.coordinate)))
    fig.suptitle('Rigid Body Test {:}'.format(str(reference_signal.coordinate)))
    responses.plot(ax[0])
    ax[0].set_ylabel('Acceleration')
    reference_signal.plot(ax[1])
    ax[1].set_ylabel('Force')
    # Truncate the initial portions of the functions so we eliminate the
    # transient portion of the response
    responses = responses.extract_elements_by_abscissa(1,np.inf)
    reference_signal = reference_signal.extract_elements_by_abscissa(1,np.inf)
    # Now we want to create an FRF from the references and responses
    frf = sdpy.TransferFunctionArray.from_time_data(reference_signal,responses,samples_per_frame)
    # Now let's create a shapearray object so we can plot the shapes
    rb_shape = frf.to_shape_array(rb_frequency)
    rb_shapes.append(rb_shape)
    
# Combine all rb_shapes into one shape array
rb_shapes = np.concatenate(rb_shapes)

# Now let's plot those shapes on our (incorrect) geometry
test_geometry_error.plot_shape(rb_shapes,plot_options);
Integrating Rigid Body Excitation at ['2796Z+']
Integrating Rigid Body Excitation at ['11722Y+']
Integrating Rigid Body Excitation at ['2796X+']
<Figure size 640x480 with 2 Axes>
<Figure size 640x480 with 2 Axes>
<Figure size 640x480 with 2 Axes>
Responses to the excitation applied to the structure

When we plot the measured rigid shapes against the geometry where we intentionally introduce errors, we see that one of the sensors on the wing seems to be moving opposite the rest of the wing. We can see that by visualizing rigid body response, we are able to identify incorrect test geometry. We will investigate this more thoroughly in a moment.

Exploring Data Objects

Before we move on to analyzing the rigid body datasets, we will first take some time to explore some of the data objects that we have created. Namely, we generated some TimeHistoryArray objects and a TransferFunctionArray object for each excitation degree of freedom.

In SDynPy, all data objects inherit from the NDDataArray class, which represents a general, multi-dimensional data container. This class, in turn, inherits from the SdynpyArray, so all the broadcasting and attribute access features are available as well.

We will start by examining responses from the previous block of code. We see that it is a TimeHistoryArray object. We can look at its dtype to see what data it contains.

responses
TimeHistoryArray with shape 90 and 316000 elements per function
responses.dtype
dtype([('abscissa', '<f8', (316000,)), ('ordinate', '<f8', (316000,)), ('comment1', '<U80'), ('comment2', '<U80'), ('comment3', '<U80'), ('comment4', '<U80'), ('comment5', '<U80'), ('coordinate', [('node', '<u8'), ('direction', 'i1')], (1,))])

Being a TimeHistoryArray object, responses consists of real data. It has floating point abscissa and ordinate fields to contain the independent time and dependent response variables, respectively. Note that the abscissa and ordinate fields have the size of the length of the data record; in this case, it the time data consists of 316,000 samples. It also has five 80-character string fields where comments can be stored. Finally, it has a coordinate field that stores the degree of freedom information for each data record. Note the rather peculiar length 1 on the coordinate field. This is to signify that a time history signal can be considered a 1D data array (one degree of freedom per signal), which we will see is contrary to, for example, TransferFunctionArray objects which are 2D, having both a response (output) coordinate and a reference (input) coordinate for each signal. Again, remember that these extra field dimensions are appended to the TimeHistoryArray dimensions.

responses.shape
(90,)
responses.ordinate.shape
(90, 316000)
responses.coordinate.shape
(90, 1)

Consider the previous object in contrast to the frf variable, which is a TransferFunctionArray object.

frf
TransferFunctionArray with shape 90 x 1 and 16001 elements per function
frf.dtype
dtype([('abscissa', '<f8', (16001,)), ('ordinate', '<c16', (16001,)), ('comment1', '<U80'), ('comment2', '<U80'), ('comment3', '<U80'), ('comment4', '<U80'), ('comment5', '<U80'), ('coordinate', [('node', '<u8'), ('direction', 'i1')], (2,))])

TransferFunctionArray has all the same fields as TimeHistoryArray, except they are different shapes and types. Because frequency response data is complex, the ordinate field is now a 16-byte complex number rather than an 8-byte floating point number. Similarly, because there are now reference and response coordinates associated with each data record, the length of the coordinate field is now 2.

Identifying Bad Geometry with Rigid Body Checkouts in SDynPy

Now that we’ve understood the data objects a bit better, we can return to the task at hand, which is to sort out our geometry and channel table, which has an intentionally incorrect sensor.

SDynPy has some built-in tools for doing rigid body checkouts, plotting the complex plane and the residual discussed previously. These are contained in the sdpy.shape.rigid_body_check function. Simply give the function the geometry and shapes, and it will attempt to figure out which nodes are suspicious and warrant further investigation.

# It looks like there is an error in the shapes (go figure!).  Let's perform
# a more quantitative analysis on the shapes to see what is wrong
suspicious_dofs = sdpy.shape.rigid_body_check(
    test_geometry_error, rb_shapes)
<Figure size 640x480 with 1 Axes>
<Figure size 640x480 with 1 Axes>
<Figure size 640x480 with 1 Axes>
<Figure size 640x480 with 1 Axes>

The first three plots generated by sdpy.shape.rigid_body_check are the complex plane. These plots allow us to visualize the phase of the responses compared to the reference signal. For a force-to-acceleration FRF, we should see the deflection shape be primarily real when far from elastic mode shapes, meaning the imaginary part should be small compared to the real part. Indeed, we see that the imaginary part is several orders of magnitude smaller than the real part.

The last plot generated by sdpy.shape.rigid_body_check is the rigid body residual plot. This is a measure of how “rigid” the deflection shapes look. SDynPy does a best fit of the deflection shapes from the test to a set of rigid body shapes created analytically from the geometry. Sensors that don’t match their analytically created counterparts will show a high residual. This analysis has clearly identified the degrees of freedom at node 6214 as having high residuals, and this is exactly the sensor we applied the error to.

While the example problem shown here only has external sensors, many times internal sensors may be suspicious, and without visual access to them, it can be difficult to investigate the potential incorrect orientations of the sensors. For this reason, SDynPy includes an approach to identify the “best” orientation of the sensor using the sdpy.shape.rigid_body_fix_node_orientation function. If we give this function a geometry, a set of rigid shapes, and a list of suspicious nodes, it will attempt to find the correct orientation of of the sensors at the suspicious nodes.

# Let's see if we can't let SDynPy figure out the correct orientation for that
# sensor in the geometry given the data.
suspicious_nodes = np.unique(suspicious_dofs.node)
test_geometry_corrected = sdpy.shape.rigid_body_fix_node_orientation(
    test_geometry_error, rb_shapes,suspicious_nodes)
    
# Let's see what the fix looks like compared to the way the sensor is actually
# oriented
test_geometry_error.plot_coordinate(
    sdpy.coordinate.from_nodelist(suspicious_nodes),label_dofs=True,
    plot_kwargs=plot_options)
test_geometry_corrected.plot_coordinate(
    sdpy.coordinate.from_nodelist(suspicious_nodes),label_dofs=True,
    plot_kwargs=plot_options)
test_geometry.plot_coordinate(
    sdpy.coordinate.from_nodelist(suspicious_nodes),label_dofs=True,
    plot_kwargs=plot_options)
    
# Plot the rigid shapes on the corrected geometry
test_geometry_corrected.plot_shape(rb_shapes,plot_options);
Optimizing Orientation of Node 5248
Optimizing Orientation of Node 6157
Optimizing Orientation of Node 6214
Optimizing Orientation of Node 6376
Optimizing Orientation of Node 11705
Optimizing Orientation of Node 13603
Optimizing Orientation of Node 13660
Optimizing Orientation of Node 18787
Optimizing Orientation of Node 19579
Optimizing Orientation of Node 19651

We see that when we plotted the coordinate systems, SDynPy was able to take the initial erroneous coordinate system (left) and correct it (center) so that it matched the original coordinate system (right) that we had before we introduced the instrumentation error.

Corrected coordinate systems

We see now that when we plot the shapes on the corrected geometry, it indeed looks like rigid motion.

Rigid body shaes plotted with corrected geometry

Running a Virtual Experiment: Modal Testing

Now that we have validated test geometry, it is time to perform a modal test. Again, we will simulate this virtually for this test.

The first thing we will do is select drive points to get all of the modes of the system. We will simply select positions here intuitively. We will select wing tips, tail tip, and nose points.

drive_points = sdpy.coordinate_array(string_array=[
    '6157Z+',
    '11705Z+',
    '18787Y+',
    '5248Y+',
    ])

test_geometry.plot_coordinate(drive_points,plot_kwargs=plot_options,
                                label_dofs=True);
Drive Points used for a MIMO modal test

Now we would like to set up a force for our modal test. Here we will use a random excitation, which will enable us to excite the structure with all drive points simultaneously, which results in a Multiple-Input, Multiple-Output modal test.

We can set up a random force using the sdpy.generator.random function or the sdpy.TimeHistoryArray.random_signal method. Again, we will choose the latter because it automatically provides a sdpy.TimeHistoryArray object instead of a np.ndarray. We will provide it the time step size, the total number of samples, the degrees of freedom, and a high-frequency cutoff to ensure that we don’t have aliasing when we downsample from the integration oversampling factor back to our test bandwidth. We will accept default parameters for rms level and other parameters.

modal_frames = 30
references_modal = sdpy.TimeHistoryArray.random_signal(
    dt, # dt already has the 10x oversampling
    signal_length = samples_per_frame,
    coordinates = drive_points,
    max_frequency=test_bandwidth, # Limit frequency content to nyquist
    frames=modal_frames)

We should plot the signal and its frequency content to verify we have set it up correctly.

references_modal.plot(one_axis=False);
<Figure size 640x480 with 4 Axes>

We can easily compute the FFT of the signal using sdpy.TimeHistoryArray.fft. We should see that we only have frequency content up to the test bandwidth of 200 Hz.

references_modal.fft().plot(one_axis=False);
<Figure size 640x480 with 4 Axes>

We will again do integration of our test system using the System.time_integrate function, and we will then downsample the resulting data to our test bandwidth. We can again pass the reference and response data into the sdpy.TransferFunctionArray.from_time_data function to compute FRFs. Note that because we now have random data, we will use a window function and apply an overlap.

# Now let's run the modal test
responses_modal = test_system.time_integrate(
    references_modal,
    response_dofs)

# Now let's downsample to the actual measurement (removing the 10x integration
# oversample)
responses_sampled = responses_modal.downsample(integration_oversample)
references_sampled = references_modal.downsample(integration_oversample)

# Compute FRFs.
frf_sampled = sdpy.TransferFunctionArray.from_time_data(
    references_sampled,responses_sampled,samples_per_frame//integration_oversample,
    overlap=0.5,window='hann')

Since we now have a large number of FRFs, it can be difficult to visualize them all simultaneously, so we will use SDynpy’s GUIPlot to allow us to quickly look through all the functions to ensure they look right.

# Now let's use GUIPlot to look at the functions
gp = sdpy.GUIPlot(frf_sampled)
GUI Plot showing modal FRFs

Fitting Modes using PolyPy

Now that we have frequency response functions created, we can fit modes to them. SDynPy has two mode fitters implemented, PolyPy and SMAC. Both curve fitters can be used via graphical user interface or via Python commands if it is desirable to automate the curve fitting. This example will use the sdpy.PolyPy_GUI approach.

Running PolyPy

We open the PolyPy GUI by initializing the sdpy.PolyPy_GUI class with our frequency response function dataset frf_sampled. We can also set the geometry used to plot shapes here. Alternatively, we could load the geometry from disk through the GUI.

ppgui = sdpy.PolyPy_GUI(frf_sampled)
ppgui.set_geometry(test_geometry)

The initial screen shows mode indicator functions, as well as options for computing the initial stabilization diagram. We can see from the shown Complex Mode Indicator function that there are a few instances of closely-spaced modes. We can drag the frequency region on the figure to select the frequency range of interest, set the polynomial orders, and press the button to compute the stabilization curve.

Setting up Parameters to compute the stabilization plot in PolyPy

Once the stabilization plot is computed, stable poles can be selected by clicking on them in the stabilization plot. Once all poles are selected, shapes can be computed.

Selecting poles on the stabilization plot in PolyPy

If the Auto-Resynthesize Checkbox is checked, then PolyPy will compute shapes and resynthesize after each pole is selected. For large datasets, this can be computationally intensive, so the checkbox can be unchecked. If the box is not checked, the Resynthesize button can be clicked to manually trigger a resynthesis.

To visualize the resynthesis quality, we can click on one of the buttons in the Resynthesis portion of the Select Poles tab. For example, clicking the CMIF button will compute CMIFs for the experimental and resynthesized FRFs. In this case, since the data is synthetic, we should expect very good fits.

Visualizing the Modal Fits

To visualize fit shapes, the Plot Shapes button can be clicked. If we hadn’t loaded a geometry already we would have had to press the Load Geometry button to load a geometry onto which the shapes could be plotted.

When we are satisfied with our fit shapes, we can save them to disk by clicking the Export Shapes... button and choosing a file name. Here we will chose shapes_polypy.npy as the file name.

Comparing Test and Finite Element Modes

Once modes are fit in the GUI and saved to disk, we can load them back into our analysis. We initially compute a Modal Assurance Criterion matrix between the fit shapes and our finite element shapes to compare the results. We can also print a shape comparison table obtained by the sdpy.shape.shape_comparison_table function.

# In the PolyPy GUI we saved the shapes to disk, so we will now load them.
test_shapes_polypy = sdpy.shape.load('shapes_polypy.npy')

# Let's compare the shapes to the finite element model shapes
mac = sdpy.shape.mac(test_shapes,test_shapes_polypy)
ax = sdpy.correlation.matrix_plot(
    mac,text_size=5)
shape_correspondences = np.where(mac > 0.9)
shape_1 = test_shapes_polypy[shape_correspondences[1]]
shape_2 = test_shapes[shape_correspondences[0]]
ax.set_ylabel('FEM Shapes')
ax.set_xlabel('Test Shapes')
print(sdpy.shape.shape_comparison_table(shape_1, shape_2,
                                        percent_error_format='{:0.4f}%'))
  Mode  Freq 1 (Hz)  Freq 2 (Hz)  Freq Error  Damp 1  Damp 2  Damp Error  MAC
     1         6.01         6.00     0.2244%   2.36%   2.00%    17.8660%  100
     2        13.41        13.40     0.0498%   2.05%   2.00%     2.6895%  100
     3        30.59        30.60    -0.0313%   2.01%   2.00%     0.4683%  100
     4        30.73        30.74    -0.0271%   2.01%   2.00%     0.3724%  100
     5        31.73        31.73     0.0036%   1.99%   2.00%    -0.5099%  100
     6        33.31        33.31    -0.0032%   2.00%   2.00%     0.2107%  100
     7        39.01        39.01     0.0009%   2.00%   2.00%    -0.1821%  100
     8        46.77        46.77     0.0063%   1.98%   2.00%    -0.9293%  100
     9        47.28        47.27     0.0161%   2.00%   2.00%    -0.2117%  100
    10        57.49        57.49    -0.0018%   2.01%   2.00%     0.3476%  100
    11        66.02        66.02    -0.0010%   2.00%   2.00%     0.1072%  100
    12        75.28        75.28    -0.0006%   2.00%   2.00%     0.2393%  100
    13        92.58        92.58    -0.0012%   2.00%   2.00%    -0.0508%  100
    14        95.39        95.40    -0.0080%   2.00%   2.00%     0.1561%  100
    15        97.19        97.20    -0.0045%   2.00%   2.00%    -0.1698%  100
    16        99.94        99.94    -0.0000%   2.00%   2.00%    -0.1136%  100
    17       107.30       107.30    -0.0021%   2.00%   2.00%     0.1278%  100
    18       138.97       138.96     0.0064%   1.99%   2.00%    -0.4992%  100
    19       140.81       140.81     0.0005%   2.01%   2.00%     0.3016%  100
    20       142.06       142.04     0.0098%   2.00%   2.00%     0.2211%  100
    21       148.12       148.13    -0.0034%   2.01%   2.00%     0.3476%  100
    22       158.70       158.70     0.0002%   2.01%   2.00%     0.3235%  100
    23       164.16       164.15     0.0062%   2.00%   2.00%     0.1392%  100
    24       172.71       172.68     0.0181%   1.98%   2.00%    -0.8442%  100
    25       172.98       172.96     0.0146%   1.98%   2.00%    -0.8069%  100
    26       183.52       183.53    -0.0035%   1.98%   2.00%    -0.9884%  100

<Figure size 640x480 with 2 Axes>

We can see that we achieve very good comparisons to the analytical shapes. Obviously we have not fit the rigid body shapes in the test, so there is correlation between the first six FEM modes and any of the test shapes. Similarly the bandwidth of our test was truncated, so we do not see correlation of test shapes to the higher frequency FEM modes. One thing to note is the increase of damping at low frequency in our fit modes from the simulated test. This is due to the application of the Hann window when computing FRFs; the window function artificially pushes the signals to zero, which artificially increases the damping. This affects lower frequency modes more significantly because they tend to take longer to damp out for an equivalent damping ratio. Note that all window functions disrupt data, they just disrupt data less than the leakage that would otherwise be obtained when processing the random signals!

Another way to compare shapes is to overlay them. This is easily done within SDynPy using the sdpy.shape.overlay_shapes function. The output of this function is a “combined” geometry and “combined” shape. The nodes, coordinate systems, elements, and tracelines are all combined into one geometry, and the id values of each of these elements is offset to avoid conflicts. The shape degrees of freedom are also concatenated and offset similarly to produce the appropriate shape visualization.

Note that our test shapes might be 180 degrees out of phase with the finite element model shapes, so we will first compute the dot product between the two sets of shapes and flip the sign on any shape where the dot product is negative. We can do this easily using the shape_alignment method.

# Compare shapes visually.  First we need to get the correct flipping in case
# the shapes are 180 out of phase
shape_phasing = sdpy.shape.shape_alignment(shape_1,shape_2)
shape_1 = shape_1*shape_phasing[:,np.newaxis]

# Plot on the test geometry
test_comparison_geometry,test_comparison_shapes = sdpy.shape.overlay_shapes(
    (test_geometry,test_geometry),(shape_1,shape_2),[1,7])
test_comparison_geometry.plot_shape(test_comparison_shapes,plot_options)
# Plot on the fem geometry
fem_comparison_geometry,fem_comparison_shapes = sdpy.shape.overlay_shapes(
    geometries = (test_geometry,geometry_global),
    shapes = (shape_1,shapes_global[shape_correspondences[0]]),
    color_override = [1,7])
fem_comparison_geometry.plot_shape(
    fem_comparison_shapes,plot_options,
    deformed_opacity=0.5,undeformed_opacity=0);
Test Shapes overlaid with FEM shapes on test geometryTest Shapes overlaid with FEM shapes on FEM geometry

SEREP Expansion

Often times, the “stick” test geometry can be insufficient for communicating results to test stakeholders, particularly when there are only uniaxial sensors on the test. Among other things, the System Equivalent Reduction Expansion Process (SEREP) can be useful for expanding data at test sensors out to the full finite element space for better visualization. SDynPy makes it easy to perform SEREP using the ShapeArray.expand method. Particularly in this case where we have kept the test geometry node IDs equivalent to the original finite element node IDs, the expansion bookkeeping is handled automatically.

We first need to create the set of shapes to use to expand the test shapes. These will be the finite element shapes in the test bandwidth. We then call the ShapeArray.expand method, giving it the test geometry, finite element geometry, and expansion basis shapes. Note here that the global shapes and global geometry are used in the expansion. All coordinate transformations between the local test geometry and global finite element geometry are handled automatically by SDynPy.

# Perform the expansion using the finite element shapes in the bandwidth
expansion_basis = shapes_global[shapes_global.frequency < shape_bandwidth]
expanded_shapes = test_shapes_polypy.expand(test_geometry,geometry_global,
                                                expansion_basis)
# We can then plot the expanded shapes on the original finite element geometry
geometry_global.plot_shape(expanded_shapes,plot_options)

# Or overlay the original and expanded data
expansion_comparison_geometry,expansion_comparison_shapes = sdpy.shape.overlay_shapes(
    (test_geometry,geometry_global),
    (test_shapes_polypy,expanded_shapes),
    [1,7])
expansion_comparison_geometry.plot_shape(
    expansion_comparison_shapes, plot_options,
    deformed_opacity=0.5, undeformed_opacity=0);
Test Shapes expanded to FEM GeometryTest Shapes overlaid with Expanded Test Shapes

Summary

This document performed a walkthrough of a typical experimental modal analysis. A FEM was loaded in to perform test design studies. Optimized instrumentation locations were selected from this model.

We then created a test geometry to enable us to plot the eventual mode shapes from the test. We noted that instrumentation is tedious and error prone, so we saw how SDynPy gives us the ability to identify incorrectly documented sensors in the channel table or test geometry and a way to fix improperly oriented sensors in the geometry if we don’t have physical access to them to verify their orientation. For these studies we used a “rigid body” test where we excited the structure with low frequency sine waves and measured the rigid response.

After we validated the geometry, we performed a modal test. We did a MIMO Random modal test with four shaker locations. We computed FRFs from the time data and then fit modes. We noted that the PolyPy mode fitter was able to handle closely spaced modes and was able to fit all the modes in the bandwidth.

We then compared the modes from the synthetic test data back to the finite element model, noting that we got exceptionally good fits to the data. We also demonstrated finite element expansion using SEREP.