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

.. contents::

Imports
-------

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

.. code-block:: python

    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
    
    plt.close('all') # close all plots
    
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
:py:func:`sdpy.Geometry.plot<sdynpy.core.sdynpy_geometry.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.

.. code-block:: python

    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 <https://www.osti.gov/servlets/purl/10102115>`_ file format to store
output data.  We can load in an Exodus file into SDynPy using the
:py:class:`sdpy.Exodus<sdynpy.fem.sdynpy_exodus.Exodus>` class.  Additionally,
we can reduce the size of the finite element model by reducing it to just the
outer surfaces using
:py:func:`sdpy.Exodus.reduce_to_surfaces<sdynpy.fem.sdynpy_exodus.Exodus.reduce_to_surfaces>`,
which returns a
:py:class:`sdpy.ExodusInMemory<sdynpy.fem.sdynpy_exodus.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.

.. code-block:: python

    # 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()
    
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
:py:class:`Geometry<sdynpy.core.sdynpy_geometry.Geometry>` as well as
:py:class:`Shapes<sdynpy.core.sdynpy_shape.ShapeArray>`.

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 
:py:func:`sdpy.geometry.from_exodus<sdynpy.core.sdynpy_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]``.

.. code-block:: python

    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 
:py:class:`Geometry<sdynpy.core.sdynpy_geometry.Geometry>` object.  If we
simply type ``geometry`` into the IPython console after running the previous
command, we get out the representation of the
:py:class:`Geometry<sdynpy.core.sdynpy_geometry.Geometry>` object (truncated
here to save space)


.. code-block:: console

    In [1]: geometry
    Out[1]: 
    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
      .
      .
      .
    
    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
      .
      .
      .
    
    Traceline
       Index,     ID,          Description, Color, # Nodes
    ----------- Empty -------------
    
    Element
       Index,     ID, Type, Color, # Nodes
        (0,),      1,   64,     1,       4
        (1,),      2,   64,     1,       4
        (2,),      3,   64,     1,       4
        (3,),      4,   64,     1,       4
        (4,),      5,   64,     1,       4
        (5,),      6,   64,     1,       4
        (6,),      7,   64,     1,       4
        (7,),      8,   64,     1,       4
        (8,),      9,   64,     1,       4
        (9,),     10,   64,     1,       4
      .
      .
      .
      
We see there are a large number of
:py:class:`nodes<sdynpy.core.sdynpy_geometry.NodeArray>`,
:py:class:`coordinate systems<sdynpy.core.sdynpy_geometry.CoordinateSystemArray>`,
and :py:class:`elements<sdynpy.core.sdynpy_geometry.ElementArray>`, but no
:py:class:`tracelines<sdynpy.core.sdynpy_geometry.TracelineArray>`.
We can access these data by accessing the
:py:class:`Geometry<sdynpy.core.sdynpy_geometry.Geometry>` attributes:
``geometry.node``, ``geometry.coordinate_system``, ``geometry.element``, and
``geometry.traceline``.

Nodes
^^^^^

We will start with the :py:class:`NodeArray<sdynpy.core.sdynpy_geometry.NodeArray>`
object revealed by ``geometry.node``.  The
:py:class:`NodeArray<sdynpy.core.sdynpy_geometry.NodeArray>` class is a subclass of
:py:class:`SdynpyArray<sdynpy.core.sdynpy_array.SdynpyArray>`, which is
itself a subclass of NumPy's
`ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_.
All subclasses of :py:class:`SdynpyArray<sdynpy.core.sdynpy_array.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 :py:class:`SdynpyArray<sdynpy.core.sdynpy_array.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']``, 
:py:class:`SdynpyArray<sdynpy.core.sdynpy_array.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 :py:class:`SdynpyArray<sdynpy.core.sdynpy_array.SdynpyArray>` subclasses
have a :py:attr:`fields<sdynpy.core.sdynpy_array.SdynpyArray.fields>` 
attribute that lists the fields stored in the array that can be accessed.

Returning to the
:py:class:`geometry.node<sdynpy.core.sdynpy_geometry.NodeArray>`, we can
identify the fields in the object using the command

.. code-block:: console

    In [2]: geometry.node.fields
    Out[2]: ('id', 'coordinate', 'color', 'def_cs', 'disp_cs')
    
Here we see the five fields of the
:py:class:`NodeArray<sdynpy.core.sdynpy_geometry.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``.

.. code-block:: console

    In [3]: geometry.node.dtype
    Out[3]: 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 :py:class:`SdynpyArray<sdynpy.core.sdynpy_array.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``.

.. code-block:: console

    In [4]: geometry.node.shape
    Out[4]: (12686,)

    In [5]: geometry.node.coordinate.shape
    Out[5]: (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
:py:class:`Geometry<sdynpy.core.sdynpy_geometry.Geometry>` object are stored
in a 
:py:class:`CoordinateSystemArray<sdynpy.core.sdynpy_geometry.CoordinateSystemArray>`
object that can be accessed by ``geometry.coordinate_system``.  We will again
explore the fields of the 
:py:class:`CoordinateSystemArray<sdynpy.core.sdynpy_geometry.CoordinateSystemArray>`
using the ``dtype``.

.. code-block:: console

    In [6]: geometry.coordinate_system.dtype
    Out[6]: 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 
:py:class:`NodeArray<sdynpy.core.sdynpy_geometry.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
:py:class:`CoordinateSystemArray<sdynpy.core.sdynpy_geometry.CoordinateSystemArray>`
to the shape of its ``matrix`` field, we will see that the latter has 2 extra
dimensions of length 4 and 3.

.. code-block:: console

    In [7]: geometry.coordinate_system.shape
    Out[7]: (12687,)

    In [8]: geometry.coordinate_system.matrix.shape
    Out[8]: (12687, 4, 3)
    
Elements
^^^^^^^^

Elements in the 
:py:class:`Geometry<sdynpy.core.sdynpy_geometry.Geometry>` are stored in an
:py:class:`ElementArray<sdynpy.core.sdynpy_geometry.ElementArray>` object, which
can be accessed using the ``geometry.element`` attribute.  The fields of this
object are

.. code-block:: console

    In [9]: geometry.element.dtype
    Out[9]: dtype([('id', '<u8'), ('type', 'u1'), ('color', '<u2'), ('connectivity', 'O')])
    
Like :py:class:`NodeArray<sdynpy.core.sdynpy_geometry.NodeArray>` and
:py:class:`CoordinateSystemArray<sdynpy.core.sdynpy_geometry.CoordinateSystemArray>`
objects, the :py:class:`ElementArray<sdynpy.core.sdynpy_geometry.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
:py:class:`Geometry<sdynpy.core.sdynpy_geometry.Geometry>` object is the
:py:class:`TracelineArray<sdynpy.core.sdynpy_geometry.TracelineArray>`,
which represents a line connecting nodes in the geometry.  The fields of the
:py:class:`TracelineArray<sdynpy.core.sdynpy_geometry.TracelineArray>` object
are

.. code-block:: console

    In [10]: geometry.traceline.dtype
    Out[10]: dtype([('id', '<u8'), ('color', '<u2'), ('description', '<U40'), ('connectivity', 'O')])
    
Similarly to the other geometry objects, 
:py:class:`TracelineArray<sdynpy.core.sdynpy_geometry.TracelineArray>` objects
have ``id`` and ``color``, and like to the 
:py:class:`ElementArray<sdynpy.core.sdynpy_geometry.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 :py:class:`TracelineArray<sdynpy.core.sdynpy_geometry.TracelineArray>` as
a string with less than 40 characters.

Visualizing Degrees of Freedom on a Geometry
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Now that we have an understanding of our 
:py:class:`Geometry<sdynpy.core.sdynpy_geometry.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
:py:class:`CoordinateArray<sdynpy.core.sdynpy_coordinate.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
:py:func:`from_nodelist<sdynpy.core.sdynpy_coordinate.CoordinateArray.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.  

.. code-block:: python

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

Here again is a good place to explore what makes up a
:py:class:`CoordinateArray<sdynpy.core.sdynpy_coordinate.CoordinateArray>`
object, which we have just assigned to the variable ``coordinates``.  We can
examine the data type of the 
:py:class:`CoordinateArray<sdynpy.core.sdynpy_coordinate.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. 

.. code-block:: console

    In [11]: coordinates.dtype
    Out[11]: dtype([('node', '<u8'), ('direction', 'i1')])
    
:py:class:`CoordinateArray<sdynpy.core.sdynpy_coordinate.CoordinateArray>`
objects store the direction as an integer where rotations are encoded:

+------------+------------------+
|Direction   | Integer Encoding |
+============+==================+
|    X+      |        1         |
+------------+------------------+
|    Y+      |        2         |
+------------+------------------+
|    Z+      |        3         |
+------------+------------------+
|    RX+     |        4         |
+------------+------------------+
|    RY+     |        5         |
+------------+------------------+
|    RZ+     |        6         |
+------------+------------------+
|    X-      |       -1         |
+------------+------------------+
|    Y-      |       -2         |
+------------+------------------+
|    Z-      |       -3         |
+------------+------------------+
|    RX-     |       -4         |
+------------+------------------+
|    RY-     |       -5         |
+------------+------------------+
|    RZ-     |       -6         |
+------------+------------------+
|    None    |        0         |
+------------+------------------+

When we want to read
:py:class:`CoordinateArray<sdynpy.core.sdynpy_coordinate.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.

.. code-block:: console

    In [12]: coordinates
    Out[12]:
    array(['1X+', '1Y+', '1Z+', ..., '20496X+', '20496Y+', '20496Z+'],
          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
:py:class:`CoordinateArray<sdynpy.core.sdynpy_coordinate.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 
:py:func:`plot_coordinate<sdynpy.core.sdynpy_geometry.Geometry.plot_coordinate>`
method of the :py:class:`Geometry<sdynpy.core.sdynpy_geometry.Geometry>` object.

.. code-block:: python

    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.

.. image:: figures/airplane_coordinate_systems.gif
  :width: 600
  :alt: 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
:py:func:`sdpy.geometry.from_exodus<sdynpy.core.sdynpy_geometry.from_exodus>`
function, which was the global Z+ direction.

.. image:: figures/airplane_coordinate_system_closeup.png
  :width: 600
  :alt: Airplane geometry with coordinate systems shown close up.

Creating Shapes from an Exodus File
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

We just finished extracting geometry with local coordinate systems.  We will
now extract shapes from the
:py:class:`sdpy.ExodusInMemory<sdynpy.fem.sdynpy_exodus.ExodusInMemory>`
object.  To do this, we will use the 
:py:func:`sdpy.shape.from_exodus<sdynpy.core.sdynpy_shape.from_exodus>`
function.  Note that shapes gathered from this function will be in the
global coordinate system, so we will assign the variable a name that denotes
that these are global shapes.

.. code-block:: python

    shapes_global = sdpy.shape.from_exodus(fexo)
    
Shapes
^^^^^^

At this point, it is useful to explore briefly the
:py:class:`ShapeArray<sdynpy.core.sdynpy_shape.ShapeArray>` object in the
IPython console.  The data type of the object is:

.. code-block:: console

    In [13]: shapes_global.dtype
    Out[13]: 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 
:py:class:`ShapeArray<sdynpy.core.sdynpy_shape.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 
:py:class:`ShapeArray<sdynpy.core.sdynpy_shape.ShapeArray>`.  ``modal_mass``
is also stored in the present
:py:class:`ShapeArray<sdynpy.core.sdynpy_shape.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
:py:class:`CoordinateArray<sdynpy.core.sdynpy_coordinate.CoordinateArray>`
objects, and thus has the same data type as
:py:class:`CoordinateArray<sdynpy.core.sdynpy_coordinate.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 :py:class:`ShapeArray<sdynpy.core.sdynpy_shape.ShapeArray>`
object itself as its first dimensions, and then the size of the ``coordinate``
field as its last dimension.

.. code-block:: console

    In [14]: shapes_global.shape
    Out[14]: (200,)
    
    In [15]: shapes_global.shape_matrix.shape
    Out[15]: (200, 38058)

Correcting Negative Frequencies and Array Views versus Copies
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

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.

.. code-block:: python

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

Note carefully here the order of operations in the above statement.  We have a
:py:class:`ShapeArray<sdynpy.core.sdynpy_shape.ShapeArray>` object containing
our shape data.  We then access the ``frequency`` field of that 
:py:class:`ShapeArray<sdynpy.core.sdynpy_shape.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.

.. code-block:: python

    shapes_global[shapes_global.frequency < 0].frequency = 0
    
In the above line of code, we first index our
:py:class:`ShapeArray<sdynpy.core.sdynpy_shape.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.

.. code-block:: console

    In [16]: shapes_global.frequency[shapes_global.frequency < 0]
    Out[16]: 
    array([-6.79983277e-05, -6.35834268e-05, -4.75579236e-05, -2.66030128e-05,
           -2.39180665e-05])
    
    In [17]: shapes_global[shapes_global.frequency < 0].frequency
    Out[17]: 
    array([-6.79983277e-05, -6.35834268e-05, -4.75579236e-05, -2.66030128e-05,
           -2.39180665e-05])

Experienced users of NumPy should readily identify the subtle difference
between the above two operations, however.  In the second, the ``shapes_global``
:py:class:`ShapeArray<sdynpy.core.sdynpy_shape.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, as can be demonstrated in the
IPython console.

.. code-block:: console
    
    In [18]: shapes_global[shapes_global.frequency < 0].frequency
    Out[18]: 
    array([-6.79983277e-05, -6.35834268e-05, -4.75579236e-05, -2.66030128e-05,
           -2.39180665e-05])
    
    In [19]: shapes_global[shapes_global.frequency < 0].frequency = 0
    
    In [20]: shapes_global[shapes_global.frequency < 0].frequency
    Out[20]: 
    array([-6.79983277e-05, -6.35834268e-05, -4.75579236e-05, -2.66030128e-05,
           -2.39180665e-05])
           
We start by checking that there are negative frequencies, then we assign 
those frequencies to zero, then check again if there are negative frequencies.
Indeed the negative frequencies still exist!  Again to reiterate, this is
because we have assigned the zero frequencies to a copy of the original object.

The correct way to assign to field data is to do all indexing to the final
target array, which in this case is the ``frequency`` field.  By doing it this
way, the ``frequency`` field that we are referencing is still the same
``frequency`` field from the original ``shapes_global`` array, so assigning
to this array will modify the original ``shapes_global`` array's data.

.. code-block:: console
    
    In [21]: shapes_global.frequency[shapes_global.frequency < 0]
    Out[21]: 
    array([-6.79983277e-05, -6.35834268e-05, -4.75579236e-05, -2.66030128e-05,
           -2.39180665e-05])
    
    In [22]: shapes_global.frequency[shapes_global.frequency < 0] = 0
    
    In [23]: shapes_global.frequency[shapes_global.frequency < 0]
    Out[23]: array([], dtype=float64)
    
Here we see that if we do the indexing the correct way, after assigning the
``frequency`` field data to zero, there are no remaining negative frequencies
(an empty array is returned).

If the reader does not understand these concepts, they are encouraged to read
and understand the NumPy
`documentation on indexing <https://numpy.org/doc/stable/user/basics.indexing.html>`_,
otherwise misapplying these nuanced concepts can introduce bugs into analyses
performed using SDynPy.

Transforming Shape Coordinate Systems
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

As mentioned above, the shapes that come from the 
:py:func:`sdpy.shape.from_exodus<sdynpy.core.sdynpy_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
:py:func:`plot_shape<sdynpy.core.sdynpy_geometry.Geometry.plot_shape>` method of the
:py:class:`Geometry<sdynpy.core.sdynpy_geometry.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.

.. code-block:: python

    plotter = geometry.plot_shape(shapes_global,plot_options)
    
.. image:: figures/airplane_mode_wrong_cs.gif
  :width: 600
  :alt: 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 
:py:func:`transform_coordinate_system<sdynpy.core.sdynpy_shape.ShapeArray.transform_coordinate_system>`
method of the :py:class:`ShapeArray<sdynpy.core.sdynpy_shape.ShapeArray>` object.
This method takes two arguments; the first is a
:py:class:`Geometry<sdynpy.core.sdynpy_geometry.Geometry>` object that defines
the coordinate systems that we are transforming "from" and the second is a
:py:class:`Geometry<sdynpy.core.sdynpy_geometry.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
:py:func:`sdpy.geometry.from_exodus<sdynpy.core.sdynpy_geometry.from_exodus>`
function while not passing in the arguments that specify the local coordinate
systems should be created.  Plotting the same ``coordinates``
:py:class:`CoordinateArray<sdynpy.core.sdynpy_coordinate.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.

.. code-block:: python

    # 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)
    
    # Plot global shapes with global geometry
    plotter = geometry_global.plot_shape(shapes_global,plot_options)

.. image:: figures/airplane_global_coordinate_system_closeup.png
  :width: 600
  :alt: Airplane geometry with global coordinate systems shown close up.
  
.. image:: figures/airplane_mode_right_cs.gif
  :width: 600
  :alt: 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.

.. code-block:: python

    # 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)
    
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
:py:func:`sdpy.dof.by_effective_independence<sdynpy.fem.sdynpy_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.

We will first compute the maximum, minimum, and range of node positions in the
model to define the grid, and use NumPy's ``meshgrid`` function to assemble the
grid.

.. code-block:: python

    # Get the maximum and minimum of each component of the node positions (X,Y,Z)
    min_coords = geometry.node.coordinate.min(axis=0)
    max_coords = geometry.node.coordinate.max(axis=0)
    
    # Compute the size of the model in each direction
    range_coords = max_coords-min_coords
    
    # Specify the grid spacing that we want to use
    grid_spacing = 0.25
    
    # Compute the number of points along each direction in the grid
    num_grids = np.ceil(range_coords/grid_spacing).astype(int)
    
    # Create the grid in each dimension using linspace
    grid_arrays = [np.linspace(min,max,num) 
                   for min,max,num in zip(min_coords,max_coords,num_grids)]
    # Use meshgrid to assemble the array grid.  Flatten the point dimension and
    # transpose so the array has shape (n_points x 3)
    grid_points = np.array(np.meshgrid(*grid_arrays,indexing='ij')).reshape(3,-1).T
    
With the grid points defined, we can now select the nodes in the geometry
closest to these grid points by using the
:py:func:`by_position<sdynpy.core.sdynpy_geometry.NodeArray.by_position>` method
of the :py:class:`NodeArray<sdynpy.core.sdynpy_geometry.NodeArray>` object.
However, there are points on the grid that are not particularly close to any
node in the geometry (e.g. points above the wing at the level of the tail tip)
so we will further reduce the candidate nodes by only keeping those where the
closest node to a grid position is within the specified ``grid_spacing``.
The NumPy ``unique`` function can be used to eliminate repeated nodes if one
node ends up being closest to two grid points.

.. code-block:: python

    # Select nodes that are closest to the points in the grid
    candidate_nodes = geometry.node.by_position(grid_points)
    # Reduce to only nodes that are within one grid spacing of their requested point
    candidate_nodes = candidate_nodes[
        np.linalg.norm(candidate_nodes.coordinate - grid_points,axis=-1) < grid_spacing]
    # Remove duplicates
    candidate_node_ids = np.unique(candidate_nodes.id)
    
At this point we need to create the shape matrix that the effective independence
algorithm will use to downselect degrees of freedom.  We will first develop a
:py:class:`CoordinateArray<sdynpy.core.sdynpy_coordinate.CoordinateArray>`
containing the degrees of freedom of the candidate set.  We will use the helper
function :py:func:`sdpy.coordinate_array<sdynpy.core.sdynpy_coordinate.coordinate_array>`
to create this set of degrees of freedom, using NumPy's broadcasting
capabilities to fill out the array.
Here the nodes are the candidate node ids, and the directions are
1, 2, 3 corresponding to X+, Y+, Z+.  We pass in a shape 
(``candidate_node_ids.size`` x 1) array for the nodes and a shape (3) array for the
direction, which will be broadcast to a (``candidate_node_ids.size`` x 3) array
output where the rows correspond to the node id and the columns correspond to
each direction.  For more information on broadcasting, see the NumPy
`documentation <http://numpy.org/doc/stable/user/basics.broadcasting.html>`_.

.. code-block:: python

    # Create the candidate degree of freedom set
    candidate_dofs = sdpy.coordinate_array(candidate_node_ids[:,np.newaxis],[1,2,3])
    
    # Plot the degrees of freedom to verify they were created correctly
    geometry.plot_coordinate(candidate_dofs,arrow_scale=0.01,plot_kwargs = plot_options)

.. image:: figures/airplane_candidate_sensor_locations.png
  :width: 600
  :alt: Candidate sensor locations to select nodes

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.

.. code-block:: python

    # 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]
    
Finally, the last definition needed is the number of sensors to keep.  In this
case, we will want to keep 30 triaxial accelerometers.

.. code-block:: python

    # Define the shape bandwidth
    sensors_to_keep = 30
    
At this point it is illustrative to examine what the 
:py:func:`sdpy.dof.by_effective_independence<sdynpy.fem.sdynpy_dof.by_effective_independence>`.
function wants as its arguments.  The first argument is ``sensors_to_keep``,
which is the number we just defined.  The second is the ``shape_matrix``.
Reading the documentation, this ``shape_matrix`` should have its first dimension
corresponding to each sensor (here a sensor could be a channel or group of
channels for a triaxial accelerometer) and its last dimension correspond to
each target mode.  We will therefore want to set up a matrix with shape 
(``candidate_node_ids.size`` x 3 x ``target_shapes.size``).  This way, the
target shape dimension is last, and the group of channels corresponding to each
sensor is first.

Let's start setting this up.  We already have a shape
(``candidate_node_ids.size`` x 3) ``candidate_dofs``
:py:class:`CoordinateArray<sdynpy.core.sdynpy_coordinate.CoordinateArray>`.
We can use that coordinate array to index into the ``target_shapes``
:py:class:`ShapeArray<sdynpy.core.sdynpy_shape.ShapeArray>`.

.. code-block:: python

    # Get the shape matrix by indexing the target shapes with our candidate dofs
    shape_matrix = target_shapes[candidate_dofs]

Here it is illustrative to examine the sizes of the arrays in this operation in
the IPython console.

.. code-block:: console
    
    In [24]: target_shapes.shape
    Out[24]: (43,)
    
    In [25]: candidate_dofs.shape
    Out[25]: (1217, 3)
    
    In [26]: shape_matrix.shape
    Out[26]: (43, 1217, 3)
    
We see that we started with 43 shapes in our ``target_shapes`` array.  We then
index into that array with our (1217 x 3) ``candidate_dofs`` array.  This 
selects a (1217 x 3) shape matrix for each of the 43 shapes, resulting in a
final ``shape_matrix`` shape of (43 x 1217 x 3).  We are almost to the
desired shape of ``shape_matrix``.  However, as stated in the documentation, the
mode dimension of the array should be last, and the sensor dimension should be
first.  We simply need to move the first axis (index 0 in Python's 0-based
indexing) to the last index (index -1, where the negative index signifies
counting from the end of the array).  We can easily move array axes around
using NumPy's ``moveaxis`` function.

.. code-block:: python

    shape_matrix = np.moveaxis(shape_matrix,0,-1)

Now if we re-examine the shape in the IPython console, we see that it is the
proper shape.

.. code-block:: console
    
    In [27]: shape_matrix.shape
    Out[27]: (1217, 3, 43)
    
We can now run the sensor selection algorithm using the effective independence
objective function.  The sensor selection schemes are contained in
:py:mod:`sdpy.dof<sdynpy.fem.sdynpy_dof>`, and we will use the
:py:func:`sdpy.dof.by_effective_independence<sdynpy.fem.sdynpy_dof.by_effective_independence>`
function.  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
:py:func:`by_effective_independence<sdynpy.fem.sdynpy_dof.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.

.. code-block:: python

    keep_indices,efi = sdpy.dof.by_effective_independence(
        sensors_to_keep, shape_matrix,return_efi=True)
        
    # Plot the effective independence vs dofs
    fig,ax = plt.subplots(num='EFI vs DoF')
    ax.plot(shape_matrix.shape[0]-np.arange(len(efi)), efi)
    ax.set_yscale('log')
    ax.set_xlim(ax.get_xlim()[::-1])
    ax.set_ylabel('EFI')
    ax.set_xlabel('DoF Remaining')
    
    # Plot the kept dofs on the model
    keep_dofs = candidate_dofs[keep_indices]
    geometry.plot_coordinate(keep_dofs,arrow_scale=0.01,plot_kwargs = plot_options)

.. image:: figures/EFI_vs_DoF.png
  :width: 600
  :alt: Effective Independence vs Sensors remaining
  
.. image:: figures/test_dofs_on_fem.png
  :width: 600
  :alt: Test Sensors plotted on the Finite Element 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
:py:class:`tracelines<sdynpy.core.sdynpy_geometry.TracelineArray>`
to aid in visualization.  We will use the helper function
:py:func:`add_traceline<sdynpy.core.sdynpy_geometry.Geometry.add_traceline>`.

The first step in this process is to simply reduce the original geometry down
to our kept sensor locations. using the
:py:func:`reduce<sdynpy.core.sdynpy_geometry.Geometry.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.

.. code-block:: python
    
    # Create a test geometry and shapes to plot on the test geometry
    test_geometry = geometry.reduce(np.unique(keep_dofs.node))

.. code-block:: console
    
    In [28]: test_geometry
    Out[28]:
    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.

.. code-block:: python

    # Let's plot the coordinates with the dofs labeled to aid us in creating the
    # tracelines
    test_geometry.plot_coordinate(keep_dofs,arrow_scale=0.02,label_dofs=True)

.. image:: figures/test_geometry_labeled.png
  :width: 600
  :alt: Labeled degrees of freedom 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.

.. code-block:: python

    # 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_coordinate(keep_dofs,arrow_scale=0.02,label_dofs=True,plot_kwargs=plot_options)
    
.. image:: figures/test_geometry_with_tracelines_labeled.png
  :width: 600
  :alt: Labeled degrees of freedom on the test geometry with tracelines
  
It is also useful to reduce the shapes down to the test degrees of freedom,
which we can do using the
:py:func:`ShapeArray.reduce<sdynpy.core.sdynpy_shape.ShapeArray.reduce>` method,
and we will need to specify a damping value (here 2% is used) as there is no
damping in the finite element model.  We can then plot our test shapes on our
test geometry to make sure everything looks right.  We can look at the
Modal Assurance Criterion matrix of our reduced set of shapes to
ensure that we can distinguish the shapes from the test sensors.  The MAC
can be computed with the 
:py:func:`ShapeArray.mac<sdynpy.core.sdynpy_shape.mac>` function, and the
matrix can be plotted nicely using the
:py:func:`sdpy.correlation.matrix_plot<sdynpy.signal_processing.sdynpy_correlation.matrix_plot>`
function.

.. code-block:: python

    # Now get the test shapes by reducing the target shapes to the test dofs
    test_shapes = target_shapes.reduce(keep_dofs.flatten())
    # We'll need to add damping to the model too
    test_shapes.damping=0.02
    # Plot the geometry to see what it looks like
    test_geometry.plot_shape(test_shapes,plot_options)
    # Look at the mac for the target shapes with our set of degrees of freedom
    sdpy.correlation.matrix_plot(sdpy.shape.mac(test_shapes),text_size = 8)
    
.. image:: figures/test_shape.gif
  :width: 600
  :alt: Representative elastic shape plotted on the test geometry
  
.. image:: figures/test_mac.png
  :width: 600
  :alt: MAC matrix of the test shapes
  
OOPS! 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.

.. code-block:: python

    # 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]

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.

.. math::
    \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 :py:class:`System<sdynpy.core.sdynpy_system.System>`
objects to represent dynamical systems.  
:py:class:`System<sdynpy.core.sdynpy_system.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).
:py:class:`System<sdynpy.core.sdynpy_system.System>` objects also contain
a :py:class:`coordinate<sdynpy.core.sdynpy_coordinate.CoordinateArray>`
to aid in bookkeeping.

We can easily create a modal :py:class:`System<sdynpy.core.sdynpy_system.System>`
from our existing shapes by using the
:py:func:`ShapeArray.system<sdynpy.core.sdynpy_shape.ShapeArray.system>` method.
This will return a state matrix in modal space.  We can use the
:py:func:`System.spy<sdynpy.core.sdynpy_system.System.spy>` method to visualize
the structure of the created system.

.. code-block:: python

    # Create a System from the test shapes
    test_system = test_shapes.system()
    # Look at the structure of the System object
    test_system.spy()
    
.. image:: figures/modal_system_spy.png
  :width: 600
  :alt: Structure of the test system object

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
:py:class:`System<sdynpy.core.sdynpy_system.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.

.. code-block:: console

    In [29]: test_system.mass
    Out[29]: 
    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.]])
    
    In [30]: test_system.stiffness
    Out[30]: 
    array([[      0.        ,       0.        ,       0.        , ...,
                  0.        ,       0.        ,       0.        ],
           [      0.        ,       0.        ,       0.        , ...,
                  0.        ,       0.        ,       0.        ],
           [      0.        ,       0.        ,       0.        , ...,
                  0.        ,       0.        ,       0.        ],
           ...,
           [      0.        ,       0.        ,       0.        , ...,
            2868905.99964993,       0.        ,       0.        ],
           [      0.        ,       0.        ,       0.        , ...,
                  0.        , 3210105.88194563,       0.        ],
           [      0.        ,       0.        ,       0.        , ...,
                  0.        ,       0.        , 3289727.48791008]])
    
    In [31]: test_system.damping
    Out[31]: 
    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]])

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.
    
.. code-block:: console
    
    In [32]: test_system.transformation
    Out[32]: 
    array([[-2.76026978e-03, -1.40194807e-03,  4.58617331e-03, ...,
             1.73749203e-03, -3.09022372e-04, -1.68270938e-05],
           [ 6.53050658e-04, -5.80137822e-04, -2.07032636e-03, ...,
             1.46528917e-04,  6.02431820e-04, -5.76349533e-03],
           [ 1.69407153e-03,  3.83686628e-04, -7.59999135e-04, ...,
             5.49012119e-04,  2.74121905e-03,  2.04985114e-04],
           ...,
           [-8.55739072e-03,  8.65502669e-04,  9.38297408e-04, ...,
             1.34896916e-02,  3.71546727e-03,  1.47339469e-03],
           [-4.93925263e-05, -8.19915019e-03, -2.66942214e-03, ...,
             2.39905468e-03, -1.48742295e-05, -2.91024169e-05],
           [-6.55791597e-03,  3.30305914e-04, -6.16004354e-03, ...,
             1.31560277e-02,  3.15253196e-03,  1.19728196e-03]])
    
    In [33]: test_system.coordinate
    Out[33]: 
    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
:py:func:`System.time_integrate<sdynpy.core.sdynpy_system.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.  
:py:class:`System<sdynpy.core.sdynpy_system.System>` objects represent Linear,
Time-Invariant systems, so the
`scipy.signal.lsim <https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.lsim.html>`_
function is used for integration within the
:py:func:`System.time_integrate<sdynpy.core.sdynpy_system.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.

.. code-block:: python

    # 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
:py:func:`sdpy.generator.sine<sdynpy.signal_processing.sdynpy_generator.sine>`
function to produce a sine wave with the proper sampling parameters.

.. code-block:: python

    # Now we will create a sine signal that we can use for rigid body checkouts
    rb_frequency = 0.5 # Hz
    force = sdpy.generator.sine(rb_frequency, dt, samples_per_frame*rb_frames)
    # Plot the sine wave to make sure it is correct
    fig,ax = plt.subplots(num='Sine Force Signal')
    ax.plot(np.arange(samples_per_frame*rb_frames)*dt,force)
    
.. image:: figures/Sine_Force_Signal.png
  :width: 600
  :alt: Sinusoidal signal applied to the structure

Finally, we will chose the coordinates at which we will excite the structure.
We can examine our previous plots of the labeled test geometry to identify
degrees of freedom that are approximately through the center of gravity of the
structure, which will result in approximately translational motions.

.. code-block:: python

    # 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+'])

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 postiion using
:py:func:`System.time_integrate<sdynpy.core.sdynpy_system.System.time_integrate>`.
This will give us references and responses as
:py:class:`TimeHistoryArray<sdynpy.core.sdynpy_data.TimeHistoryArray>` objects.
We will then truncate off the first second of the response in order to remove
the startup transients from the data using
:py:func:`TimeHistoryArray.extract_elements_by_abscissa<sdynpy.core.sdynpy_data.TimeHistoryArray.extract_elements_by_abscissa>`.
We will then pass the truncated data into the
:py:func:`sdpy.TransferFunctionArray.from_time_data<sdynpy.core.sdynpy_data.TransferFunctionArray.from_time_data>`
function to produce frequency response functions as a
:py:class:`TransferFunctionArray<sdynpy.core.sdynpy_data.TransferFunctionArray>`
object.  Once the frequency response functions are computed, we can query the
``ordinate`` field of the 
:py:class:`TransferFunctionArray<sdynpy.core.sdynpy_data.TransferFunctionArray>`
to get the deflection shape, which we will store into a
:py:class:`ShapeArray<sdynpy.core.sdynpy_shape.ShapeArray>` object using the
:py:func:`sdpy.shape_array<sdynpy.core.sdynpy_shape.shape_array>` helper
function.  We append each shape to a running list, and then concatenate all the
shapes into one :py:class:`ShapeArray<sdynpy.core.sdynpy_shape.ShapeArray>`
object, which we can plot on our erroneous geometry.

.. code-block:: python

    # Create a list to hold our shapes
    rb_shapes = []
    # Loop through each of our excitation locations
    for rb_coordinate in rb_coordinates:
        # Perform time integration to get the responses to our sine wave
        print('Integrating Rigid Body Excitation at {:}'.format(str(rb_coordinate)))
        responses,references = test_system.time_integrate(
            force,dt,references=rb_coordinate)
        # Plot the responses and references
        fig,ax = plt.subplots(2,1,sharex=True,
                              num='Rigid Body Test {:}'.format(str(rb_coordinate)))
        responses.plot(ax[0])
        ax[0].set_ylabel('Acceleration')
        references.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)
        references = references.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(references,responses,samples_per_frame)
        # Now we want to get the value at our frequency line because the rest will
        # be noise
        frequency_index = np.argmin(abs(frf[0,0].abscissa - rb_frequency))
        shape_matrix = frf.ordinate[...,frequency_index]
        # Now let's create a shapearray object so we can plot the shapes
        rb_shape = sdpy.shape_array(frf[:,0].response_coordinate,shape_matrix.T,
                                    rb_frequency,comment1=str(rb_coordinate))
        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)

Throughout the above code, the responses and reference signal were plotted; a
representative figure is shown below, showing the sinusoidal response of the
system at the requested degrees of freedom.

.. image:: figures/example_rigid_body_test.png
  :width: 600
  :alt: 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 will investigate this
more thoroughly in a moment.
  
.. image:: figures/error_rigid_shape.gif
  :width: 600
  :alt: Rigid body shapes shown on erroneous geometry
  
Exploring Time 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 
:py:class:`TimeHistoryArray<sdynpy.core.sdynpy_data.TimeHistoryArray>` objects
and a 
:py:class:`TransferFunctionArray<sdynpy.core.sdynpy_data.TransferFunctionArray>`
object for each excitation degree of freedom.

In SDynPy, all data objects inherit from the
:py:class:`NDDataArray<sdynpy.core.sdynpy_data.NDDataArray>` class, which
represents a general, multi-dimensional data container.  This class, in turn,
inherits from the :py:class:`SdynpyArray<sdynpy.core.sdynpy_array.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 
:py:class:`TimeHistoryArray<sdynpy.core.sdynpy_data.TimeHistoryArray>` object.
We can look at its ``dtype`` to see what data it contains.

.. code-block:: console

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

Being a :py:class:`TimeHistoryArray<sdynpy.core.sdynpy_data.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, which we will see is contrary
to, for example,
:py:class:`TransferFunctionArray<sdynpy.core.sdynpy_data.TransferFunctionArray>`
objects which are 2D, having both a response (output) coordinate and a reference
(input) coordinate.  Again, remember that these extra dimensions are appended
to the :py:class:`TimeHistoryArray<sdynpy.core.sdynpy_data.TimeHistoryArray>`
dimensions.

.. code-block:: console

    In [35]: responses.shape
    Out[35]: (90,)
    
    In [36]: responses.ordinate.shape
    Out[36]: (90, 316000)
    
    In [37]: responses.coordinate.shape
    Out[37]: (90, 1)
    
Consider the previous object in contrast to the ``frf`` variable, which is a
:py:class:`TransferFunctionArray<sdynpy.core.sdynpy_data.TransferFunctionArray>`
object.

.. code-block:: console

    In [38]: frf
    Out[38]: TransferFunctionArray with shape 90 x 1 and 16000 elements per function
    
    In [39]: frf.dtype
    Out[39]: dtype([('abscissa', '<f4', (16000,)),
                    ('ordinate', '<c16', (16000,)),
                    ('comment1', '<U80'),
                    ('comment2', '<U80'),
                    ('comment3', '<U80'),
                    ('comment4', '<U80'),
                    ('comment5', '<U80'),
                    ('coordinate',
                         [('node', '<u8'), ('direction', 'i1')],
                         (2,))
                    ])
                    
:py:class:`TransferFunctionArray<sdynpy.core.sdynpy_data.TransferFunctionArray>`
has all the same fields as 
:py:class:`TimeHistoryArray<sdynpy.core.sdynpy_data.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 :py:func:`sdpy.shape.rigid_body_check<sdynpy.core.sdynpy_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.

.. code-block:: python

    # 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)

We can see on the complex plane plots that the imaginary part is significantly
smaller than the real part, meaning we are well away from elastic modes and
all of our gauges have the correct phase.  The residual plot immediately
highlights the sensors that are not behaving like they should, which allows
the test engineer to hone in on that sensor to figure out what is wrong.

.. image:: figures/rigid_body_check_complex_plane.png
  :width: 600
  :alt: Real and imaginary parts of the complex shape
  
.. image:: figures/rigid_body_check_residual.png
  :width: 600
  :alt: Residual plot showing erroneous sensors.
  
While the example problem shown here only has external sensors, many times
internal sensors may be suspicious, and without 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
:py:func:`sdpy.shape.rigid_body_fix_node_orientation<sdynpy.core.sdynpy_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.

.. code-block:: python

    # 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)

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.

.. image:: figures/geometry_error.png
  :width: 30%
  :alt: Erroneous coordinate system
.. image:: figures/geometry_corrected.png
  :width: 30%
  :alt: Corrected coordinate system
.. image:: figures/geometry_truth.png
  :width: 30%
  :alt: Truth coordinate system
  
We see now that when we plot the shapes on the corrected geometry, it indeed
looks like rigid motion.

.. image:: figures/corrected_rigid_shape.gif
  :width: 600
  :alt: Rigid body shapes 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.

.. code-block:: python

    drive_points = sdpy.coordinate_array(string_array=[
        '6157Z+',
        '11705Z+',
        '18787Y+',
        '5248Y+',
        ])
    
    test_geometry.plot_coordinate(drive_points,plot_kwargs=plot_options,
                                  label_dofs=True)
                                  
.. image:: figures/airplane_modal_drive_points.png
  :width: 600
  :alt: 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
:py:func:`sdpy.generator.random<sdynpy.signal_processing.sdynpy_generator.random>`
function.  We will provide it the number of forces, the total number of samples,
the time step, 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.

.. code-block:: python

    # Now let's create a force.  We will do a random excitation
    modal_frames = 30
    random_forces = sdpy.generator.random(
        drive_points.shape,modal_frames*samples_per_frame,dt=dt,
        high_frequency_cutoff=test_bandwidth)
    # Look at the signal statistics
    rms = np.sqrt(np.mean(random_forces**2,axis=-1))
    fig,ax = plt.subplots(2,1,num='Random Excitation')
    ax[0].plot(np.arange(random_forces.shape[-1])*dt,
               random_forces.T)
    ax[0].set_ylabel('Force')
    ax[0].set_xlabel('Time')
    freq = np.fft.rfftfreq(random_forces.shape[-1],dt)
    fft = np.fft.rfft(random_forces,axis=-1)
    ax[1].plot(freq,abs(fft.T))
    ax[1].set_ylabel('Force')
    ax[1].set_yscale('log')
    ax[1].set_xlabel('Frequency')

Here we see we have random signal with a sharp cutoff in the frequency domain.
We have 4 signals, one for each force location.

.. image:: figures/Airplane_Random_Excitation.png
  :width: 600
  :alt: Random signals used to excite the modal test
  
We will again do integration of our test system using the
:py:func:`System.time_integrate<sdynpy.core.sdynpy_system.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 
:py:func:`sdpy.TransferFunctionArray.from_time_data<sdynpy.core.sdynpy_data.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.

.. code-block:: python

    # Now let's run the modal test
    responses_modal,references_modal = test_system.time_integrate(
            random_forces,dt,references=drive_points)
    
    # Now let's downsample to the actual measurement (removing the 10x integration
    # oversample)
    responses_sampled = responses_modal.extract_elements(slice(None,None,integration_oversample))
    references_sampled = references_modal.extract_elements(slice(None,None,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
:py:class:`GUIPlot<sdynpy.core.sdynpy_data.GUIPlot>` to allow us to quickly
look through all the functions to ensure they look right.

.. code-block:: python
    
    # Now let's use GUIPlot to look at the functions
    plotter = sdpy.GUIPlot(frf_sampled)

.. image:: figures/airplane_gui_plot.png
  :width: 600
  :alt: Drive points used for a MIMO modal test.
  
Fitting Modes using PolyPy
--------------------------

Now that we have frequency response functions created, we can fit modes to them.
SDynPy has two mode fitters implemented,
:py:class:`PolyPy<sdynpy.modal.sdynpy_polypy.PolyPy_GUI>` and 
:py:class:`SMAC<sdynpy.modal.sdynpy_smac.SMAC_GUI>`.  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
:py:class:`sdpy.PolyPy_GUI<sdynpy.modal.sdynpy_polypy.PolyPy_GUI>` approach.

Running PolyPy
~~~~~~~~~~~~~~~

We open the PolyPy GUI by initializing the
:py:class:`sdpy.PolyPy_GUI<sdynpy.modal.sdynpy_polypy.PolyPy_GUI>` class
with our frequency response function dataset ``frf_sampled``

.. code-block:: python

    # Now that we have FRFs we can go fit modes.  We will first look at using
    # PolyPy
    pm = sdpy.PolyPy_GUI(frf_sampled)
    
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.

.. image:: figures/airplane_polypy_stabilization.png
  :width: 600
  :alt: 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.

.. image:: figures/airplane_polypy_stabilization_selection.png
  :width: 600
  :alt: Selecting poles on the stabilization plot in PolyPy

The final tab of the PolyPy implementation allows you to see how well the
modes fit to the measured frequency response data.  On this page, modes can be
saved to a file.

.. image:: figures/airplane_polypy_stabilization_resynthesis.png
  :width: 600
  :alt: Visualizing the modal fits in PolyPy

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
:py:func:`sdpy.shape.shape_comparison_table<sdynpy.core.sdynpy_shape.shape_comparison_table>`
function.

.. code-block:: python

    # 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)
    sdpy.correlation.matrix_plot(
        mac,text_size=6)
    shape_correspondences = np.where(mac > 0.9)
    shape_1 = test_shapes_polypy[shape_correspondences[1]]
    shape_2 = test_shapes[shape_correspondences[0]]
    print(sdpy.shape.shape_comparison_table(shape_1, shape_2,
                                            percent_error_format='{:0.4f}%'))

.. image:: figures/airplane_test_vs_fem_mac.png
  :width: 600
  :alt: MAC between fit shapes and FEM shapes

.. code-block:: console

  Mode  Freq 1 (Hz)  Freq 2 (Hz)  Freq Error  Damp 1  Damp 2  Damp Error  MAC
     1         6.00         6.00    -0.0067%   2.36%   2.00%    17.9820%  100
     2        13.40        13.40     0.0218%   2.06%   2.00%     2.9605%  100
     3        30.59        30.60    -0.0458%   2.01%   2.00%     0.4512%  100
     4        30.73        30.74    -0.0344%   2.01%   2.00%     0.4827%   99
     5        31.73        31.73     0.0077%   1.99%   2.00%    -0.7148%  100
     6        33.31        33.31     0.0025%   1.99%   2.00%    -0.3471%  100
     7        39.02        39.01     0.0114%   1.98%   2.00%    -1.0855%  100
     8        46.78        46.77     0.0156%   1.97%   2.00%    -1.4382%   99
     9        47.28        47.27     0.0189%   2.00%   2.00%    -0.0342%  100
    10        57.49        57.49    -0.0003%   2.00%   2.00%     0.1664%  100
    11        66.02        66.02     0.0012%   2.00%   2.00%    -0.0377%  100
    12        75.28        75.28    -0.0000%   2.00%   2.00%     0.0906%  100
    13        92.58        92.58    -0.0026%   2.00%   2.00%     0.1076%  100
    14        95.39        95.40    -0.0078%   2.00%   2.00%     0.1584%  100
    15        97.19        97.20    -0.0061%   1.99%   2.00%    -0.3016%  100
    16        99.94        99.94    -0.0013%   2.00%   2.00%    -0.1005%  100
    17       107.30       107.30    -0.0018%   2.00%   2.00%     0.1139%  100
    18       138.97       138.96     0.0065%   1.99%   2.00%    -0.5165%  100
    19       140.82       140.81     0.0016%   2.01%   2.00%     0.3194%  100
    20       142.06       142.04     0.0097%   2.00%   2.00%     0.2438%   99
    21       148.12       148.13    -0.0045%   2.01%   2.00%     0.3527%  100
    22       158.70       158.70     0.0005%   2.01%   2.00%     0.2717%  100
    23       164.16       164.15     0.0063%   2.00%   2.00%     0.1195%  100
    24       172.71       172.68     0.0183%   1.98%   2.00%    -0.8317%   98
    25       172.99       172.96     0.0171%   1.98%   2.00%    -0.8663%   99
    26       183.52       183.53    -0.0042%   1.98%   2.00%    -1.1180%  100
    
Another way to compare shapes is to overlay them.  This is easily done within
SDynPy using the
:py:func:`sdpy.shape.overlay_shapes<sdynpy.core.sdynpy_shape.ShapeArray.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.

.. code-block:: python

    # Compare shapes visually.  First we need to get the correct flipping in case
    # the shapes are 180 out of phase
    shape_phasing = np.sign(np.einsum('ij,ij->i',shape_1.shape_matrix,shape_2.shape_matrix))
    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(
        (test_geometry,geometry_global),(shape_1,shapes_global[shape_correspondences[0]]),[1,7])
    fem_comparison_geometry.plot_shape(fem_comparison_shapes,plot_options,
                                       deformed_opacity=0.5,undeformed_opacity=0)

.. image:: figures/airplane_shape_overlay_test.gif
  :width: 600
  :alt: Overlay of test and FEM shape on test geometry
  
.. image:: figures/airplane_shape_overlay_fem.gif
  :width: 600
  :alt: Overlay of test and FEM shape on FEM
  
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 
:py:func:`ShapeArray.expand<sdynpy.core.sdynpy_shape.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 :py:func:`ShapeArray.expand<sdynpy.core.sdynpy_shape.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.

.. code-block:: python

    # 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 geometries and shapes
    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)
                                             
.. image:: figures/airplane_serep_expansion.gif
  :width: 600
  :alt: Expanded test shapes on the shape on FEM
  
.. image:: figures/airplane_serep_expansion_overlay.gif
  :width: 600
  :alt: Overlay of test and expanded test shape on FEM