The pyLife Signal API

The signal api is the higher level API of pyLife. It is the API that you probably should be using. Some of the domain specific functions are also available as pure numpy functions. However, we highly recommend you to take a closer look at pandas and consider to adapt your application to the pandas way of doing things.

Motivation

In pyLife’s domain, we often deal with data structures that consist multiple numerical values. For example a Wöhler curve (see WoehlerCurve) consists at least of the parameters k_1, ND and SD. Optionally it can have the additional parameters k_2, TN and TS. As experience teaches us, it is advisable to put these kinds of values into one variable to avoid confusion. For example a function with more than five positional arguments often leads to hard to debugable bugs due to wrong positioning.

Moreover some of these data structures can come in different dimensionalities. For example data structures describing material behavior can come as one dataset, describing one material. They can also come as a map to a FEM mesh, for example when you are dealing with case hardened components. Then every element of your FEM mesh can have a different associated Wöhler curve dataset. In pyLife we want to deal with these kinds of mappings easily without much coding overhead for the programmer.

The pyLife signal API provides the class pylife.PylifeSignal to facilitate handling these kinds of data structures and broadcasting them to another signal instance.

This page describes the basic concept of the pyLife signal API. The next page describes the broadcasting mechanism of a pylife.PylifeSignal.

The basic concept

The basic idea is to have all the data in a signal like data structure, that can be piped through the individual calculation process steps. Each calculation process step results in a new signal, that then can be handed over to the next process step.

Signals can be for example

  • stress tensors like from an FEM-solver

  • load collectives like time signals or a rainflow matrix

  • material data like Wöhler curve parameters

From a programmer’s point of view, signals are objects of either pandas.Series or pandas.DataFrame, depending if they are one or two dimensional (see here about dimensionality).

Functions that operate on a signal are usually written as methods of an instance of as class derived from PylifeSignal. These classes are usually decorated as Series or DataFrame accessor using pandas.api.extensions.register_series_accessor() resp. pandas.api.extensions.register_dataframe_accessor().

Due to the decorators, signal accessor classes can be instantiated also as an attribute of a pandas.Series or pandas.DataFrame. The following two lines are equivalent.

Usual class instantiation:

PlainMesh(df).coordinates

Or more convenient using the accessor decorator attribute:

df.plain_mesh.coordinates

There is also the convenience function from_parameters() to instantiate the signal class from individual parameters. So a pylife.materialdata.WoehlerCurve can be instantiated in three ways.

  • directly with the class constructor

    data = pd.Series({
        'k_1': 7.0,
        'ND': 2e6,
        'SD': 320.
    })
    wc = WoehlerCurve(data)
    
  • using the pandas accessor

    data = pd.Series({
        'k_1': 7.0,
        'ND': 2e6,
        'SD': 320.
    })
    wc = data.woehler
    
  • from individual parameters

    wc = WoehlerCurve.from_parameters(k_1=7.0, ND=2e6, SD= 320.)
    

How to use predefined signal accessors

There are too reasons to use a signal accessor:

  • let it validate the accessed DataFrame

  • use a method or access a property that the accessor defines

Example for validation

In the following example we are validating a DataFrame that if it is a valid plain mesh, i.e. if it has the columns x and y.

Import the modules. Note that the module with the signal accessors (here mesh) needs to be imported explicitly.

import pandas as pd
import pylife.mesh

Create a DataFrame and have it validated if it is a valid plain mesh, i.e. has the columns x and y.

df = pd.DataFrame({'x': [1.0], 'y': [1.0]})
df.plain_mesh
<pylife.mesh.meshsignal.PlainMesh at 0x7fdca07c1a90>

Now create a DataFrame which is not a valid plain mesh and try to have it validated:

df = pd.DataFrame({'x': [1.0], 'a': [1.0]})
df.plain_mesh
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[3], line 2
      1 df = pd.DataFrame({'x': [1.0], 'a': [1.0]})
----> 2 df.plain_mesh

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pandas/core/generic.py:6204, in NDFrame.__getattr__(self, name)
   6197 if (
   6198     name not in self._internal_names_set
   6199     and name not in self._metadata
   6200     and name not in self._accessors
   6201     and self._info_axis._can_hold_identifiers_and_holds_name(name)
   6202 ):
   6203     return self[name]
-> 6204 return object.__getattribute__(self, name)

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pandas/core/accessor.py:224, in CachedAccessor.__get__(self, obj, cls)
    221 if obj is None:
    222     # we're accessing the attribute of the class, i.e., Dataset.geo
    223     return self._accessor
--> 224 accessor_obj = self._accessor(obj)
    225 # Replace the property with the accessor object. Inspired by:
    226 # https://www.pydanny.com/cached-property.html
    227 # We need to use object.__setattr__ because we overwrite __setattr__ on
    228 # NDFrame
    229 object.__setattr__(obj, self._name, accessor_obj)

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pylife/core/pylifesignal.py:62, in PylifeSignal.__init__(self, pandas_obj)
     54 """Instantiate a :class:`signal.PyLifeSignal`.
     55 
     56 Parameters
   (...)
     59 
     60 """
     61 self._obj = pandas_obj
---> 62 self._validate()

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pylife/mesh/meshsignal.py:102, in PlainMesh._validate(self)
    100 def _validate(self):
    101     self._coord_keys = ['x', 'y']
--> 102     self.fail_if_key_missing(self._coord_keys)
    103     if 'z' in self._obj.columns:
    104         self._coord_keys.append('z')

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pylife/core/pylifesignal.py:184, in PylifeSignal.fail_if_key_missing(self, keys_to_check, msg)
    153 def fail_if_key_missing(self, keys_to_check, msg=None):
    154     """Raise an exception if any key is missing in a self._obj object.
    155 
    156     Parameters
   (...)
    182     :class:`stresssignal.StressTensorVoigt`
    183     """
--> 184     DataValidator().fail_if_key_missing(self._obj, keys_to_check)

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pylife/core/data_validator.py:131, in DataValidator.fail_if_key_missing(self, signal, keys_to_check, msg)
    129     the_class = stack[2][0].f_locals['self'].__class__
    130     msg = the_class.__name__ + ' must have the items %s. Missing %s.'
--> 131 raise AttributeError(msg % (', '.join(keys_to_check), ', '.join(missing_keys)))

AttributeError: PlainMesh must have the items x, y. Missing y.

Example for accessing a property

Get the coordinates of a 2D plain mesh

df = pd.DataFrame({'x': [1.0, 2.0, 3.0], 'y': [1.0, 2.0, 3.0]})
df.plain_mesh.coordinates
x y
0 1.0 1.0
1 2.0 2.0
2 3.0 3.0

Now a 3D mesh

df = pd.DataFrame({'x': [1.0], 'y': [1.0], 'z': [1.0], 'foo': [42.0], 'bar': [23.0]})
df.plain_mesh.coordinates
x y z
0 1.0 1.0 1.0

Defining your own signal accessors

If you want to write a processor for signals you need to put the processing functionality in an accessor class that is derived from the signal accessor base class like for example Mesh. This class you register as a pandas DataFrame accessor using a decorator

import pandas as pd
import pylife.mesh

@pd.api.extensions.register_dataframe_accessor('my_mesh_processor')
class MyMesh(meshsignal.Mesh):
    def do_something(self):
        # ... your code here
        # the DataFrame is accessible by self._obj
        # usually you would calculate a DataFrame df to return it.
        df = ...
        # you might want copy the index of self._obj to the returned
        # DataFrame.
        return df.set_index(self._obj.index)

As MyMesh is derived from Mesh the validation of Mesh is performed. So in the method do_something() you can rely on that self._obj is a valid mesh DataFrame.

You then can use the class in the following way when the module is imported.

Performing additional validation

Sometimes your signal accessor needs to perform an additional validation on the accessed signal. For example you might need a mesh that needs to be 3D. Therefore you can reimplement _validate() to perform the additional validation. Make sure to call _validate() of the accessor class you are deriving from like in the following example.

import pandas as pd
import pylife.mesh

@pd.api.extensions.register_dataframe_accessor('my_only_for_3D_mesh_processor')
class MyOnlyFor3DMesh(pylife.mesh.PlainMesh):
    def _validate(self):
        super()._validate() # call PlainMesh._validate()
        self.fail_if_key_missing(['z'])

df = pd.DataFrame({'x': [1.0], 'y': [1.0]})
df.my_only_for_3D_mesh_processor
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[6], line 11
      8         self.fail_if_key_missing(['z'])
     10 df = pd.DataFrame({'x': [1.0], 'y': [1.0]})
---> 11 df.my_only_for_3D_mesh_processor

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pandas/core/generic.py:6204, in NDFrame.__getattr__(self, name)
   6197 if (
   6198     name not in self._internal_names_set
   6199     and name not in self._metadata
   6200     and name not in self._accessors
   6201     and self._info_axis._can_hold_identifiers_and_holds_name(name)
   6202 ):
   6203     return self[name]
-> 6204 return object.__getattribute__(self, name)

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pandas/core/accessor.py:224, in CachedAccessor.__get__(self, obj, cls)
    221 if obj is None:
    222     # we're accessing the attribute of the class, i.e., Dataset.geo
    223     return self._accessor
--> 224 accessor_obj = self._accessor(obj)
    225 # Replace the property with the accessor object. Inspired by:
    226 # https://www.pydanny.com/cached-property.html
    227 # We need to use object.__setattr__ because we overwrite __setattr__ on
    228 # NDFrame
    229 object.__setattr__(obj, self._name, accessor_obj)

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pylife/core/pylifesignal.py:62, in PylifeSignal.__init__(self, pandas_obj)
     54 """Instantiate a :class:`signal.PyLifeSignal`.
     55 
     56 Parameters
   (...)
     59 
     60 """
     61 self._obj = pandas_obj
---> 62 self._validate()

Cell In[6], line 8, in MyOnlyFor3DMesh._validate(self)
      6 def _validate(self):
      7     super()._validate() # call PlainMesh._validate()
----> 8     self.fail_if_key_missing(['z'])

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pylife/core/pylifesignal.py:184, in PylifeSignal.fail_if_key_missing(self, keys_to_check, msg)
    153 def fail_if_key_missing(self, keys_to_check, msg=None):
    154     """Raise an exception if any key is missing in a self._obj object.
    155 
    156     Parameters
   (...)
    182     :class:`stresssignal.StressTensorVoigt`
    183     """
--> 184     DataValidator().fail_if_key_missing(self._obj, keys_to_check)

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pylife/core/data_validator.py:131, in DataValidator.fail_if_key_missing(self, signal, keys_to_check, msg)
    129     the_class = stack[2][0].f_locals['self'].__class__
    130     msg = the_class.__name__ + ' must have the items %s. Missing %s.'
--> 131 raise AttributeError(msg % (', '.join(keys_to_check), ', '.join(missing_keys)))

AttributeError: MyOnlyFor3DMesh must have the items z. Missing z.

Defining your own signals

The same way the predefined pyLife signals are defined you can define your own signals. Let’s say, for example, that in your signal there needs to be the columns alpha, beta, gamma all of which need to be positive.

You would put the signal class into a module file my_signal_mod.py

import pandas as pd
from pylife import PylifeSignal

@pd.api.extensions.register_dataframe_accessor('my_signal')
class MySignal(PylifeSignal):
    def _validate(self):
        self.fail_if_key_missing(['alpha', 'beta', 'gamma'])
        for k in ['alpha', 'beta', 'gamma']:
            if (self._obj[k] < 0).any():
                raise ValueError("All values of %s need to be positive. "
                                 "At least one is less than 0" % k)

    def some_method(self):
        return self._obj[['alpha', 'beta', 'gamma']] * -3.0

You can then validate signals and/or call some_method().

Validation success.

df = pd.DataFrame({'alpha': [1.0, 2.0], 'beta': [1.0, 0.0], 'gamma': [1.0, 2.0]})
df.my_signal.some_method()
alpha beta gamma
0 -3.0 -3.0 -3.0
1 -6.0 -0.0 -6.0

Validation fails because of missing gamma column.

 df = pd.DataFrame({'alpha': [1.0, 2.0], 'beta': [1.0, -1.0]})
 df.my_signal.some_method()
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[9], line 2
      1 df = pd.DataFrame({'alpha': [1.0, 2.0], 'beta': [1.0, -1.0]})
----> 2 df.my_signal.some_method()

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pandas/core/generic.py:6204, in NDFrame.__getattr__(self, name)
   6197 if (
   6198     name not in self._internal_names_set
   6199     and name not in self._metadata
   6200     and name not in self._accessors
   6201     and self._info_axis._can_hold_identifiers_and_holds_name(name)
   6202 ):
   6203     return self[name]
-> 6204 return object.__getattribute__(self, name)

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pandas/core/accessor.py:224, in CachedAccessor.__get__(self, obj, cls)
    221 if obj is None:
    222     # we're accessing the attribute of the class, i.e., Dataset.geo
    223     return self._accessor
--> 224 accessor_obj = self._accessor(obj)
    225 # Replace the property with the accessor object. Inspired by:
    226 # https://www.pydanny.com/cached-property.html
    227 # We need to use object.__setattr__ because we overwrite __setattr__ on
    228 # NDFrame
    229 object.__setattr__(obj, self._name, accessor_obj)

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pylife/core/pylifesignal.py:62, in PylifeSignal.__init__(self, pandas_obj)
     54 """Instantiate a :class:`signal.PyLifeSignal`.
     55 
     56 Parameters
   (...)
     59 
     60 """
     61 self._obj = pandas_obj
---> 62 self._validate()

Cell In[7], line 7, in MySignal._validate(self)
      6 def _validate(self):
----> 7     self.fail_if_key_missing(['alpha', 'beta', 'gamma'])
      8     for k in ['alpha', 'beta', 'gamma']:
      9         if (self._obj[k] < 0).any():

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pylife/core/pylifesignal.py:184, in PylifeSignal.fail_if_key_missing(self, keys_to_check, msg)
    153 def fail_if_key_missing(self, keys_to_check, msg=None):
    154     """Raise an exception if any key is missing in a self._obj object.
    155 
    156     Parameters
   (...)
    182     :class:`stresssignal.StressTensorVoigt`
    183     """
--> 184     DataValidator().fail_if_key_missing(self._obj, keys_to_check)

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pylife/core/data_validator.py:131, in DataValidator.fail_if_key_missing(self, signal, keys_to_check, msg)
    129     the_class = stack[2][0].f_locals['self'].__class__
    130     msg = the_class.__name__ + ' must have the items %s. Missing %s.'
--> 131 raise AttributeError(msg % (', '.join(keys_to_check), ', '.join(missing_keys)))

AttributeError: MySignal must have the items alpha, beta, gamma. Missing gamma.

Validation fail because one beta is negative.

 df = pd.DataFrame({'alpha': [1.0, 2.0], 'beta': [1.0, -1.0], 'gamma': [1.0, 2.0]})
 df.my_signal.some_method()
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[10], line 2
      1 df = pd.DataFrame({'alpha': [1.0, 2.0], 'beta': [1.0, -1.0], 'gamma': [1.0, 2.0]})
----> 2 df.my_signal.some_method()

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pandas/core/accessor.py:224, in CachedAccessor.__get__(self, obj, cls)
    221 if obj is None:
    222     # we're accessing the attribute of the class, i.e., Dataset.geo
    223     return self._accessor
--> 224 accessor_obj = self._accessor(obj)
    225 # Replace the property with the accessor object. Inspired by:
    226 # https://www.pydanny.com/cached-property.html
    227 # We need to use object.__setattr__ because we overwrite __setattr__ on
    228 # NDFrame
    229 object.__setattr__(obj, self._name, accessor_obj)

File ~/checkouts/readthedocs.org/user_builds/pylife/envs/latest/lib/python3.11/site-packages/pylife/core/pylifesignal.py:62, in PylifeSignal.__init__(self, pandas_obj)
     54 """Instantiate a :class:`signal.PyLifeSignal`.
     55 
     56 Parameters
   (...)
     59 
     60 """
     61 self._obj = pandas_obj
---> 62 self._validate()

Cell In[7], line 10, in MySignal._validate(self)
      8 for k in ['alpha', 'beta', 'gamma']:
      9     if (self._obj[k] < 0).any():
---> 10         raise ValueError("All values of %s need to be positive. "
     11                          "At least one is less than 0" % k)

ValueError: All values of beta need to be positive. At least one is less than 0

Additional attributes in your own signals

If your accessor class needs to have attributes other than the accessed object itself you can define default values in the __init__() of your accessor and set these attributes with setter methods.

import pandas as pd
from pylife import PylifeSignal

@pd.api.extensions.register_dataframe_accessor('my_signal')
class MySignal(PylifeSignal):
    def __init__(self, pandas_obj):
        super(MySignal, self).__init__(pandas_obj)
        self._my_attribute = 'the default value'

    def set_my_attribute(self, my_attribute):
        self._my_attribute = my_attribute
        return self

    def do_something(self, some_parameter):
        # ... use some_parameter, self._my_attribute and self._obj
>>> df.my_signal.set_my_attribute('foo').do_something(2342)

Registering a method to an existing accessor class

Note

This functionality might be dropped on the way to pyLife-2.0 as it turns out that it is not that much used.

One drawback of the accessor class API is that you cannot extend accessors by deriving from them. For example if you need a custom equivalent stress function you cannot add it by deriving from StressTensorEquistress, and register it by the same accessor equistress.

The solution for that is register_method() that lets you monkey patch a new method to any class deriving from PylifeSignal.

from pylife import equistress

@pl.signal_register_method(equistress.StressTensorEquistress, 'my_equistress')
def my_equistress_method(df)
    # your code here
    return ...

Then you can call the method on any DataFrame that is accessed by equistress:

>>> df.equistress.my_equistress()

You can also have additional arguments in the registered method:

from pylife import equistress

@pl.signal_register_method(equistress.StressTensorEquistress, 'my_equistress_with_arg')
def my_equistress_method_with_arg(df, additional_arg)
    # your code here
    return ...
>>> df.equistress.my_equistress_with_arg(my_additional_arg)