Overview of the Settings Attribute#

OpenPNM objects all include a settings attribute which contains certain information used by OpenPNM. The best example is the algorithm classes, which often require numerous settings such as number of iterations and tolerance for iterative calculations. This tutorial will provide an overview of how these settings work, both from the user perspective as well as for developers.

[1]:
import openpnm as op
pn = op.network.Cubic([4, 4,])
geo = op.geometry.SpheresAndCylinders(network=pn, pores=pn.Ps, throats=pn.Ts)
air = op.phases.Air(network=pn)
phys = op.physics.Basic(network=pn, phase=air, geometry=geo)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Input In [1], in <cell line: 4>()
      2 pn = op.network.Cubic([4, 4,])
      3 geo = op.geometry.SpheresAndCylinders(network=pn, pores=pn.Ps, throats=pn.Ts)
----> 4 air = op.phases.Air(network=pn)
      5 phys = op.physics.Basic(network=pn, phase=air, geometry=geo)

AttributeError: module 'openpnm' has no attribute 'phases'

Normal Usage#

This section is relevant to users of OpenPNM, while the next section is more relevant to developers

Let’s look an algorithm that has numerous settings:

[2]:
alg = op.algorithms.ReactiveTransport(network=pn, phase=air)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [2], in <cell line: 1>()
----> 1 alg = op.algorithms.ReactiveTransport(network=pn, phase=air)

NameError: name 'air' is not defined

We can see that many default settings are already present by printing the settings attribute:

[3]:
print(alg.sets)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [3], in <cell line: 1>()
----> 1 print(alg.sets)

NameError: name 'alg' is not defined

We can override these settings manually:

[4]:
alg.sets.prefix = 'rxn'
print(alg.sets)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [4], in <cell line: 1>()
----> 1 alg.sets.prefix = 'rxn'
      2 print(alg.sets)

NameError: name 'alg' is not defined

We could also have updated these settings when creating the algorithm object by passing in a set of arguments. This can be in the form of a dictionary:

[5]:
s = {"prefix": "rxn"}
alg = op.algorithms.ReactiveTransport(network=pn, phase=air, settings=s)
print(alg.sets)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [5], in <cell line: 2>()
      1 s = {"prefix": "rxn"}
----> 2 alg = op.algorithms.ReactiveTransport(network=pn, phase=air, settings=s)
      3 print(alg.sets)

NameError: name 'air' is not defined

Or as a ‘dataclass’ style, which is how things are done behind the scenes in OpenPNM as described in the section:

[6]:
class MySettings:
    prefix = 'rxn'
# alg = op.algorithms.ReactiveTransport(network=pn, phase=air, settings=MySettings())
# print(alg.sets)

One new feature on OpenPNM V3 is that the datatype of some settings is enforced. For instance the 'prefix' setting must be a str, otherwise an error is raised:

[7]:
from traits.api import TraitError
try:
    alg.sets.phase = 1
except TraitError as e:
    print(e)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [7], in <cell line: 2>()
      1 from traits.api import TraitError
      2 try:
----> 3     alg.sets.phase = 1
      4 except TraitError as e:
      5     print(e)

NameError: name 'alg' is not defined

OpenPNM uses the traits package to control this behavior, which will be explained in more detail in the next section.

Advanced Usage#

The following sections are probably only relevant if you plan to do some development in OpenPN

In the previous section we saw how to define settings, as well as the data-type protections of some settings. In this section we’ll demonstrate this mechanism in more detail.

OpenPNM has two settings related classes: SettingsData and SettingsAttr. The first is a subclass of the HasTraits class from the traits package. It preceeded the Python dataclass by many years and offers far more functionality. For our purposes the main difference is that dataclasses allow developers to specify the type of attributes (i.e. obj.a must be an int), but these are only enforced during object creation. Once the object is made, any value can be assigned to a. The traits package offers the same functionality but also enforces the type of a for all subsequent assignments. We saw this in action in the previous section when we tried to assign an integer to alg.sets.prefix.

The SettingsData and HasTraits Classes#

Let’s dissect this process:

[8]:
from openpnm.utils import SettingsData, SettingsAttr
from traits.api import Int, Str, Float, List, Set
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
Input In [8], in <cell line: 1>()
----> 1 from openpnm.utils import SettingsData, SettingsAttr
      2 from traits.api import Int, Str, Float, List, Set

ImportError: cannot import name 'SettingsData' from 'openpnm.utils' (/home/runner/work/OpenPNM/OpenPNM/openpnm/utils/__init__.py)
[9]:
class CustomSettings(SettingsData):
    a = Int()
    b = Float(4.4)
    c = Set()
    d = List(Str)

s = CustomSettings()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [9], in <cell line: 1>()
----> 1 class CustomSettings(SettingsData):
      2     a = Int()
      3     b = Float(4.4)

NameError: name 'SettingsData' is not defined

Now we can print s to inspect the settings. We’ll see some default values for things that were not initialized like a, while b is the specified value.

[10]:
print(s)
{'prefix': 'rxn'}

The traits package enforces the datatype of each of these attributes:

[11]:
s.a = 2
s.b = 5.5
print(s)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Input In [11], in <cell line: 1>()
----> 1 s.a = 2
      2 s.b = 5.5
      3 print(s)

AttributeError: 'dict' object has no attribute 'a'

Let’s look at the attribute protection in action again:

[12]:
try:
    s.a = 1.1
except TraitError as e:
    print(e)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Input In [12], in <cell line: 1>()
      1 try:
----> 2     s.a = 1.1
      3 except TraitError as e:
      4     print(e)

AttributeError: 'dict' object has no attribute 'a'

The traits package also enforces the type of values we can put into the list stored in d:

[13]:
s.d.append('item')
try:
    s.d.append(100)
except TraitError as e:
    print(e)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Input In [13], in <cell line: 1>()
----> 1 s.d.append('item')
      2 try:
      3     s.d.append(100)

AttributeError: 'dict' object has no attribute 'd'

The first one works because we specified a list of strings, while the second fails because it is attempting to write an integer.

Also, we can’t accidentally overwrite an attribute that is supposed to be a list with a scalar:

[14]:
try:
    s.d = 5
except TraitError as e:
    print(e)
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Input In [14], in <cell line: 1>()
      1 try:
----> 2     s.d = 5
      3 except TraitError as e:
      4     print(e)

AttributeError: 'dict' object has no attribute 'd'

Gotcha With the HasTraits Class#

When defining a set of custom settings using the HasTraits or SettingsData class, you MUST specify a type for each attribute value. If not then it is essentially ignored.

[15]:
class MySettings(SettingsData):
    a = Int(1)
    b = 2

mysets = MySettings()
print(mysets)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [15], in <cell line: 1>()
----> 1 class MySettings(SettingsData):
      2     a = Int(1)
      3     b = 2

NameError: name 'SettingsData' is not defined

However, if you create a custom class from a basic python object it will work:

[16]:
class MySettings:
    a = 1
    b = 2

mysets = MySettings()
print(mysets.a, mysets.b)
1 2

The SettingsAttr Class#

The problem with the HasTraits class is that there is are lot of helper methods attached to it. This means that when we use the autocomplete functionality of our favorite IDEs (spyder and jupyter), we will have a hard time finding the attributes we set amongst the noise. For this reason we have created a wrapper class called SettingsAttr which works as follows:

[17]:
S = SettingsAttr(s)
print(S)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [17], in <cell line: 1>()
----> 1 S = SettingsAttr(s)
      2 print(S)

NameError: name 'SettingsAttr' is not defined

Importantly only the the user-created attributes show up, which can be test using the dir() command:

[18]:
dir(S)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [18], in <cell line: 1>()
----> 1 dir(S)

NameError: name 'S' is not defined

SettingsAttr has as few additional features. You can add a new batch of settings after instantiation as follows:

[19]:
s_new = {'a': 5, 'e': 6}
S._update(s_new)
print(S)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [19], in <cell line: 2>()
      1 s_new = {'a': 5, 'e': 6}
----> 2 S._update(s_new)
      3 print(S)

NameError: name 'S' is not defined

We can see the updated value of a, as well as the newly added e. Because e contained an integer (6), the datatype of e will be forced to remain an integer:

[20]:
try:
    S.e = 5.5
except TraitError as e:
    print(e)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [20], in <cell line: 1>()
      1 try:
----> 2     S.e = 5.5
      3 except TraitError as e:
      4     print(e)

NameError: name 'S' is not defined

Note that the _update method begins with an underscore. This prevents it from appearing in the autocomplete menu to ensure it stays clean.

For the sake of completeness, it should also be mentioned that the CustomSettings object which was passed to the SettingsAttr constructor was stored under _settings. The SettingsAttr class has overloaded __getattr__ and __setattr__ methods which dispatch the values to the _settings attribute:

[21]:
S.d is S._settings.d
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [21], in <cell line: 1>()
----> 1 S.d is S._settings.d

NameError: name 'S' is not defined

Another aspect to keep in mind is that the _settings attribute is a HasTraits object. This means that all values added to the settings must have an enforced datatype. This is done on the fly, based on the type of value received. For instance, once you set an attribute to string for instance, its type is set:

[22]:
S.f = 'a string'
try:
    S.f = 1.0
except TraitError as e:
    print(e)
print(S)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [22], in <cell line: 1>()
----> 1 S.f = 'a string'
      2 try:
      3     S.f = 1.0

NameError: name 'S' is not defined

Adding Documentation to a SettingsData and SettingsAttr Class#

One the main reasons for using a dataclass style object for holding settings is so that docstrings for each attribute can be defined and explained:

[23]:
class DocumentedSettingsData(SettingsData):
    r"""
    A class that holds the following settings.

    Parameters
    ----------
    name : str
        The name of the object
    id_num : int
        The id number of the object
    """
    name = Str('foo')
    id_num = Int(0)

d = DocumentedSettingsData()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [23], in <cell line: 1>()
----> 1 class DocumentedSettingsData(SettingsData):
      2     r"""
      3     A class that holds the following settings.
      4     
   (...)
     10         The id number of the object
     11     """
     12     name = Str('foo')

NameError: name 'SettingsData' is not defined
[24]:
print(d.__doc__)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [24], in <cell line: 1>()
----> 1 print(d.__doc__)

NameError: name 'd' is not defined

Note that this docstring was written when we defined DocumentedSettingsData subclass and it attached to it, but we’ll be interacting with the SettingsAttr class. When a SettingsAttr is created is adopts the docstring of the received settings object. This can be either a proper SettingsData/HasTraits class or a basic dataclass style object. The docstring can only be set on initialization though, so any new attributes that are created by adding values to the object (i.e. D.zz_top = 'awesome') will not be documented.

[25]:
D = SettingsAttr(d)
print(D.__doc__)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [25], in <cell line: 1>()
----> 1 D = SettingsAttr(d)
      2 print(D.__doc__)

NameError: name 'SettingsAttr' is not defined

This machinery was designed with the idea of inheriting docstrings using the docrep package. The following illustrates not only how the SettingsData class can be subclassed to add new settings (e.g. from GenericTransport to ReactiveTransport), but also how to use the hightly under-rated docrep package to also inherit the docstrings:

[26]:
import docrep
docstr = docrep.DocstringProcessor()


# This docorator tells docrep to fetch the docstring from this class and make it available elsewhere:
@docstr.get_sections(base='DocumentSettingsData', sections=['Parameters'])
class DocumentedSettingsData(SettingsData):
    r"""
    A class that holds the following settings.

    Parameters
    ----------
    name : str
        The name of the object
    id_num : int
        The id number of the object
    """
    name = Str('foo')
    id_num = Int(0)


# This tells docrep to parse this docstring and insert text at the %
@docstr.dedent
class ChildSettingsData(DocumentedSettingsData):
    r"""
    A subclass of DocumentedSettingsData that holds some addtional settings

    Parameters
    ----------
    %(DocumentSettingsData.parameters)s
    max_iter : int
        The maximum number of iterations to do
    """
    max_iter = Int(10)


E = ChildSettingsData()
print(E.__doc__)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [26], in <cell line: 6>()
      2 docstr = docrep.DocstringProcessor()
      5 # This docorator tells docrep to fetch the docstring from this class and make it available elsewhere:
      6 @docstr.get_sections(base='DocumentSettingsData', sections=['Parameters'])
----> 7 class DocumentedSettingsData(SettingsData):
      8     r"""
      9     A class that holds the following settings.
     10     
   (...)
     16         The id number of the object
     17     """
     18     name = Str('foo')

NameError: name 'SettingsData' is not defined

And we can also see that max_iter was added to the values of name and id_num on the parent class:

[27]:
E.visible_traits()
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [27], in <cell line: 1>()
----> 1 E.visible_traits()

NameError: name 'E' is not defined

Again, as mentioned above, this inherited docstring is adopted by the SettingsAttr:

[28]:
S = SettingsAttr(E)
print(S.__doc__)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [28], in <cell line: 1>()
----> 1 S = SettingsAttr(E)
      2 print(S.__doc__)

NameError: name 'SettingsAttr' is not defined

Attaching to an OpenPNM Object#

The SettingsAttr wrapper class is so named because it is meant to be an attribute (i.e. attr) on OpenPNM objects. These attached to the settings attribute:

[29]:
isinstance(alg.sets, SettingsAttr)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [29], in <cell line: 1>()
----> 1 isinstance(alg.sets, SettingsAttr)

NameError: name 'alg' is not defined

OpenPNM declares SettingsData classes with each file where class is defined, then this is attached upon initialization. This is illustrated below:

[30]:
class SpecificSettings(SettingsData):
    a = Int(4)


class SomeAlg:
    def __init__(self, settings={}, **kwargs):
        self.settings = SettingsAttr(SpecificSettings())
        self.settings._update(settings)


alg = SomeAlg()
print(alg.settings)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [30], in <cell line: 1>()
----> 1 class SpecificSettings(SettingsData):
      2     a = Int(4)
      5 class SomeAlg:

NameError: name 'SettingsData' is not defined

Or with some additional user-defined settings and overrides:

[31]:
s = {'name': 'bob', 'a': 3}
alg2 = SomeAlg(settings=s)
print(alg2.settings)
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Input In [31], in <cell line: 2>()
      1 s = {'name': 'bob', 'a': 3}
----> 2 alg2 = SomeAlg(settings=s)
      3 print(alg2.settings)

NameError: name 'SomeAlg' is not defined