Data Model

This document describes the general data model used by SDynPy objects. Understanding how SDynPy stores data can aid in understanding how to use SDynPy.

SDynPy objects generally come in two ‘flavors’.

  • Python Classes

  • NumPy Structured Arrays

General Python classes are used to represent objects that are typically used one-at-a-time. These typically include types like Geometry, which represent a test or analysis geometry. Alternatively, certain types of objects are often used in the form of an array. For example, a structural dynamics test can in general measure many channels of data, and this data is conveniently represented as an array of data. To facilitate efficient array storage and usage, these objects are often represented as subclasses of NumPy arrays. In particular, NumPy’s Structured Arrays allow the inclusion of multiple fields with varying data types for each entry in the array. In SDynPy, these objects generally include the identifier Array in their name. Examples of such types include NodeArray which represents nodes or spatial points in a geometry, TimeHistoryArray which represents a set of time response data, or ShapeArray which represents a set of mode shapes.

Accessing attributes and methods of general Python classes is relatively straightforward for those who are familiar with Python’s object-oriented programming syntax, so it will not be described here. Instead users are directed to the Python Documentation to learn about these general concepts.

This document will focus on the data model used by SDynPy to represent array-like objects by subclassing NumPy’s structured array.

SDynPy Arrays

All array-like SDynPy objects subclass from the SdynpyArray class, which in turn subclasses from the numpy.ndarray class. Therefore, most SDynPy array objects can directly use NumPy functions. As an example, you can easily select the unique degrees of freedom from a CoordinateArray using numpy.unique or concatenate arrays together using numpy.concatenate.

In the following example, we create a handful of CoordinateArray objects, show that they are indeed a CoordinateArray while also being a SdynpyArray and a ndarray. We also show how we can directly use NumPy functions with these objects.

[1]:
# Import packages
import sdynpy as sdpy
import numpy as np

# Create some coordinate arrays
array_1 = sdpy.coordinate_array(node=[1,2,3],direction='X+')
array_2 = sdpy.coordinate_array(node=[1,2,3],direction='Y+')
array_3 = sdpy.coordinate_array(node=[1,2,3],direction='X+')

print(f'array_1 is a NumPy ndarray: {isinstance(array_1,np.ndarray)}')
print(f'array_1 is a SdynpyArray: {isinstance(array_1,sdpy.SdynpyArray)}')
print(f'array_1 is a CoordinateArray: {isinstance(array_1,sdpy.CoordinateArray)}\n')

# Concatenate arrays together using numpy
full_array = np.concatenate((array_1,array_2,array_3))
print(f'Full array: {full_array}')

# Get just the unique coordinates
unique_dofs = np.unique(full_array)
print(f'Unique degrees of freedom: {unique_dofs}')
array_1 is a NumPy ndarray: True
array_1 is a SdynpyArray: True
array_1 is a CoordinateArray: True

Full array: ['1X+' '2X+' '3X+' '1Y+' '2Y+' '3Y+' '1X+' '2X+' '3X+']
Unique degrees of freedom: ['1X+' '1Y+' '2X+' '2Y+' '3X+' '3Y+']

Some NumPy functions will not return the SDynPy object, but rather the basic NumPy structured array that represents the object. An example of this is numpy.array. However, we can easily use the view method to transform the object back to the desired SDynPy object.

[2]:
# Create a 2D array using np.array
array_2d = np.array((array_1,array_2,array_3))

print(f'Created array_2d using np.array:\n{array_2d}')

# Check what type of object it is
print(f'array_2d is a NumPy ndarray: {isinstance(array_2d,np.ndarray)}')
print(f'array_2d is a SdynpyArray: {isinstance(array_2d,sdpy.SdynpyArray)}')
print(f'array_2d is a CoordinateArray: {isinstance(array_2d,sdpy.CoordinateArray)}\n')

# We can turn it back into a coordinate array using view
array_2d = array_2d.view(sdpy.CoordinateArray)

print(f'Used view to transform the np.ndarray back to a SDynPy object:\n{array_2d}')

# Check what type of object it is
print(f'array_2d is a NumPy ndarray: {isinstance(array_2d,np.ndarray)}')
print(f'array_2d is a SdynpyArray: {isinstance(array_2d,sdpy.SdynpyArray)}')
print(f'array_2d is a CoordinateArray: {isinstance(array_2d,sdpy.CoordinateArray)}\n')
Created array_2d using np.array:
[[(1, 1) (2, 1) (3, 1)]
 [(1, 2) (2, 2) (3, 2)]
 [(1, 1) (2, 1) (3, 1)]]
array_2d is a NumPy ndarray: True
array_2d is a SdynpyArray: False
array_2d is a CoordinateArray: False

Used view to transform the np.ndarray back to a SDynPy object:
[['1X+' '2X+' '3X+']
 ['1Y+' '2Y+' '3Y+']
 ['1X+' '2X+' '3X+']]
array_2d is a NumPy ndarray: True
array_2d is a SdynpyArray: True
array_2d is a CoordinateArray: True

SDynPy Array Fields

NumPy’s structured array allows multiple fields to be stored within a single array, which allows users to efficiently store and access those data. The fields of a structure array, and therefore the fields of a SdynpyArray, are defined by the array’s dtype. For example, each entry in a CoordinateArray contains a node as an unsigned 8-byte integer and a direction as a signed 1-byte integer.

[3]:
array_2d.dtype
[3]:
dtype([('node', '<u8'), ('direction', 'i1')])

To get a simple list of field names, SdynpyArray objects have a fields attribute that can be used.

[4]:
array_2d.fields
[4]:
('node', 'direction')

When working with NumPy structured arrays, and therefore SdynpyArray objects, we can access the data in these fields by indexing using the field name. This will usually return a basic NumPy array containing the data. However, sometimes it will return other SDynPy objects.

[5]:
array_2d['node']
[5]:
array([[1, 2, 3],
       [1, 2, 3],
       [1, 2, 3]], dtype=uint64)

Some users find this syntax unintuitive. Therefore, SDynPy also allows users to access these fields as if they were attributes of the object itself. Note, however, that these attributes are created dynamically, therefore many code-completion tools in development environments will not recognize these attributes.

[6]:
array_2d.node
[6]:
array([[1, 2, 3],
       [1, 2, 3],
       [1, 2, 3]], dtype=uint64)

Storing SDynPy Data to Disk

SdynpyArray objects can be saved to and loaded from the disk using functionality similar to that of NumPy, and are indeed stored as NumPy .npz or .npy files. Because it is trival to go between NumPy arrays and SDynPy arrays using view, the functionality is basically identical.

When loading a SDynPy array from a NumPy file, we will generally use a class method to perform this operation; this tells SDynPy which class to view the object as. These load functions are also often aliased to the module containing the class definition as well to make access easier.

[7]:
# Save to a file
array_2d.save('file_path.npy')
# Load from a file using a class method
array_2d_from_file = sdpy.CoordinateArray.load('file_path.npy')
# These are also often aliased to the relevant module.
array_2d_from_file = sdpy.coordinate.load('file_path.npy')

Because Matlab is another common tool used in Structural Dynamics, SDynPy provides methods to save data to a .mat file. This uses the savemat function from scipy.io.

[8]:
array_2d.savemat('file_path.mat')

Indexing SDynPy Arrays

Because SDynPy arrays are actually NumPy arrays, all NumPy indexing syntax can be used. See https://numpy.org/doc/2.2/user/basics.indexing.html for a complete description of indexing options available in SDynPy.

One key concept to understand when indexing both NumPy and SDynPy objects is when these operations return views into or copies of an array. Subtle bugs can be introduced into code if a user modifies a view of an array when they thought they had a copy. Additionally, assignments into SDynPy fields can result in unintuitive behavior if these concepts are not well understood.

Consider the following example where we try to assign to the first row of the node field of array_2d:

[9]:
print(array_2d)
[['1X+' '2X+' '3X+']
 ['1Y+' '2Y+' '3Y+']
 ['1X+' '2X+' '3X+']]

Imagine we would like to change the value of the node field for the entire first row. There are many ways to do this. For example, we could slice the entire first row and assign to the node field of that slice.

[10]:
array_2d[0,:].node = 10
print(array_2d)
[['10X+' '10X+' '10X+']
 ['1Y+' '2Y+' '3Y+']
 ['1X+' '2X+' '3X+']]

We could also assign directly to a slice of the node field.

[11]:
array_2d.node[0,:] = 20
print(array_2d)
[['20X+' '20X+' '20X+']
 ['1Y+' '2Y+' '3Y+']
 ['1X+' '2X+' '3X+']]

We can also use advanced indexing techniques such as integer indexing or logical indexing, explicitly selecting columns 0, 1, and 2 from row 0.

[12]:
array_2d.node[0,[0,1,2]] = 30
print(array_2d)
[['30X+' '30X+' '30X+']
 ['1Y+' '2Y+' '3Y+']
 ['1X+' '2X+' '3X+']]

However, think about what happens if we use advanced indexing on the CoordinateArray and then try to assign to the node field

[13]:
array_2d[0,[0,1,2]].node = 40
print(array_2d)
[['30X+' '30X+' '30X+']
 ['1Y+' '2Y+' '3Y+']
 ['1X+' '2X+' '3X+']]

We can see that the values the node field for the first row of array_2d were not assigned to 40, but rather remain as 30. This is because when we do array_2d[0,[0,1,2]], the advanced indexing triggers the creation of a copy of the original array, and we then change the value of the copy. Since this copy has no relationship to the original array, the original array does not change.

Contrast this with the first example using a slice: array_2d[0,:] is basic indexing which creates a view into the original array, meaning the view and the original array share the same memory. Therefore changing the node field of the view is equivalent to changing the node field of the original array.

Understanding the difference between copies and views for indexing operations is imperative to successfully use SDynPy. Otherwise, subtle bugs can be introduced into the code.

Iterating over SDynPy Arrays

We often would like to iterate over the items in a SDynPy array. We can do this using standard for loop syntax in Python. Note that because SDynPy arrays are NumPy arrays, iterating over a SDynPy array works exactly the same as iterating over a NumPy array. Basically, a for loop will loop over the first dimesion of the array. For a 1D array, this will return every item in the array sequentially. For a 2D array, this will return every row in the array sequentially.

[14]:
for item in array_1:
    print(item)
1X+
2X+
3X+
[15]:
for item in array_2d:
    print(item)
['30X+' '30X+' '30X+']
['1Y+' '2Y+' '3Y+']
['1X+' '2X+' '3X+']

Sometimes it is useful to iterate over every item in a multidimensional array rather than just iterating over one dimension. NumPy provides us with functions like ndenumerate to handle such cases, and will provide the index and item at each entry in the array; however, NumPy’s ndenumerate will generally return a basic NumPy structured array rather than a SDynPy array.

[16]:
for index,item in np.ndenumerate(array_2d):
    print(f'At {index}: {str(item)}')
At (0, 0): (30, 1)
At (0, 1): (30, 1)
At (0, 2): (30, 1)
At (1, 0): (1, 2)
At (1, 1): (2, 2)
At (1, 2): (3, 2)
At (2, 0): (1, 1)
At (2, 1): (2, 1)
At (2, 2): (3, 1)

Therefore SDynPy provides its own method ndenumerate which will automatically view the output as the correct SDynPy array.

[17]:
for index,item in array_2d.ndenumerate():
    print(f'At {index}: {str(item)}')
At (0, 0): 30X+
At (0, 1): 30X+
At (0, 2): 30X+
At (1, 0): 1Y+
At (1, 1): 2Y+
At (1, 2): 3Y+
At (2, 0): 1X+
At (2, 1): 2X+
At (2, 2): 3X+

Creating SDynPy Arrays

Users of NumPy will be aware that when constructing a numpy.ndarray, they can directly use the class name to invoke the class constructor. However, this will generally require the user to first create the array with the appropriate dtype and shape, and then subsequently fill it with data, which can be slightly inconvienent to use. NumPy therefor offers a convenience function, numpy.array, that can be directly passed the data to put into the array, and the function will automatically determine the shape and dtype.

Because SDynPy arrays inherit functionality from NumPy, they also can be constructed directly using the class constructor; however, similar to NumPy, this can be inconvenient. Therefore SDynPy similarly offers convenience functions to aid in creating SDynPy Array objects. These convenience functions will generally have the same name as the class name, except will use snake_case capitalization instead of the CamelCase used by the class name. For example, a NodeArray can be constructed with the node_array function, and a CoordinateArray can be constructed with the coordinate_array function.

For example, we can construct a CoordinateArray object using the constructor:

[18]:
array = sdpy.CoordinateArray((3,)) # Create an empty array with shape (3,)
array.node = [1,2,3] # Fill in the empty node numbers
array.direction = [1,1,1] # Fill in the empty directions, direction 1 corresponds to X+

print(array)
['1X+' '2X+' '3X+']

Alternatively, we can use the more convenient coordinate_array function which provides us a more convenient approach:

[19]:
array = sdpy.coordinate_array([1,2,3],['X+','X+','X+'])

print(array)
['1X+' '2X+' '3X+']

Summary

SDynPy’s data model incorporates general Python classes as well as array-like classes. These array-like classes subclass from NumPy’s ndarray to inherit much of the useful functionality of those arrays.