Contributing to PyOED#

One can contribute by adding new functionality to PyOED. Functions such as those located inside the PyOED utility subpackage are not subject to object-oriented structure and thus are straightforward to add to the package. One has to only add proper unittest to new functions to assure anticipated behavior.

Adding new Classes to the package, however, should follow PyOED’s rules as highlighted below. Since each PyOED object is associated with a configurations object, implementing or extending PyOED classes go hand in hand with implementing or extending configurations classes.

The reader is encouraged to read the following in the same order:

PyOED Objects and Configurations Objects#

What’s associated with a PyOED Object?

By design, the base PyOED object PyOEDObject, automatically associates any PyOED object that inherits it with the following (for additional details see PyOED’s configurations system):

  1. Special Attributes/Variables:

    • _CONFIGURATIONS_CLASS: a class-level variable/attribute that defines the dataclass derived from PyOEDConfigs which is used to create a configurations object. For the base PyOEDObject, this is assigned PyOEDConfigs.

    • _CONFIGURATIONS: instance of the configurations class associated with _CONFIGURATIONS_CLASS, with aggregated values (both user input and default values where user input overwrites default values). Aggregation is carried out by calling aggregate_configurations() upon instantiation. See instantiation steps below for more details.

    • _HAS_VALIDATION: bool that defines (automatically detected) whether the class has an implemented validate_configurations() method or not

  2. Methods:

  3. Properties:

How is an PyOED Object inistantiated?

Here are the steps DIAGRAM

Extending PyOED Implementations#

In all likelihood, the developer/user will want to extend one of the classes under the PyOED subpackages, namely, a model (simulation, error, or observation operator), a data assimilation algorithm, optimization algorithm, statistical model or algorithm, or an OED related object. Thus, one need to first locate the class to be inherited and extend it accordingly.

Here, we use the following example to explain how to implement new classes to extend existing PyOED implementations.

Example Description

Assume we want to develop a new class (to create an object) that is described as follows:

  1. The object’s class name is PyOEDDummyObject and is expected to inherit the PyOEDObject with the following additional configuration keys/attributes:

    1. name: string representation of the name of the object

    2. verbsoe: boolean describing screen verbosity

    3. purpose: string describing the objective of the object

  2. The object has a method named show_purpose which prints out the name and the purpose of the object.

Three main steps, the user should follow:

  1. Locate the base class that the new class/object will inherit and the corresponding configurations class

  2. Extend the base configurations and define any new configuration attributes as needed

  3. Develop the class for the intended object and assign the new configurations class to it

Locate the Base Classes to Extend#

Since we want the new PyOEDDummyOjbect to inherit PyOEDObject, we can identify the associated configurations class by calling the class-level method get_configurations_class() associated with all PyOED objects (e.g., in an interactive environment such as iPython, Jupyter, etc.). Alternatively, one can inspect the argument passed to the decorator set_configurations which precedes (associated with) the definition of nearly all classes in PyOED. The latter is equivalent to finding the value of the class variable _CONFIGURATIONS_CLASS.

By inspecting the source code of PyOEDObject, we see that it is preceded by (in this specific case, the decorator is added for completeness since _CONFIGURATIONS_CLASS is also assigned the same value as a class-level variable.)

Firt few lines of the PyOEDObject class (header) source code#
@set_configurations(configurations_class=PyOEDConfigs)
class PyOEDObject(metaclass=PyOEDABCMeta):
    _CONFIGURATIONS_CLASS: Type[PyOEDConfigs] = PyOEDConfigs
    ...

The code above shows that PyOEDObject is associated with pyoed.configs.configs.PyOEDConfigs as the configurations class. Thus, we start by extending the configurations class and define the configurations attributes needed.

Extending Configurations Ojbects/Classes#

The PyOEDConfigs is associated with the attributes debug, verbose, output_dir which are strongly typed as shown in the code snippet below.

Firt few lines of the PyOEDConfigs class (header) source code#
@dataclass(kw_only=True, slots=True)
class PyOEDConfigs(PyOEDContainer):

    debug: bool = SETTINGS.DEBUG
    verbose: bool = SETTINGS.VERBOSE
    output_dir: str | Path = SETTINGS.OUTPUT_DIR

This will be the template one need to follow in order to extend any configurations class in PyOED. Since we want our new PyOEDDummyObject to have three configuration attributes name, verbose, purpose, we need to extend PyOEDConfigs and add only name and purpose. This is achieved by the following code

Define the configurations class to the PyOEDDummyObject#
from dataclasses import dataclass
from pyoed.configs import PyOEDConfigs

@dataclass(kw_only=True, slots=True)
class PyOEDDummyObjectConfigs(PyOEDConfigs):

    name: str = "A PyOED Dummy Object"
    purpose: str = "Teaching how to create PyOED objects"

Now, PyOEDDummyObjectConfigs provides the three desired attributes in addition to debug and output_dir which are defined by the parent class. That’s it! Now, one can develop the ``PyOEDDummyObject``

Extending PyOED Objects/Classes#

Define the the PyOEDDummyObject#
from pyoed.configs import PyOEDObject, set_configurations

@set_configurations(PyOEDDummyObjectConfigs)
class PyOEDDummyObject(PyOEDObject):

    def __init__(self, configs: dict | PyOEDDummyObjectConfigs | None = None) -> None:
        # Aggregate configurations and call super
        configs = self.configurations_class.data_to_dataclass(configs)

        super().__init__(configs)

First, the decorator set_configurations adds the class variable _CONFIGURATIONS_CLASS to the PyOEDDummyObject class and assigns it the value PyOEDDummyObjectConfigs. The __init__ method of PyOEDDummyObject has a template that is generally used by all PyOED objects. First, the passed configurations configs is converted to a data class and is aggregated with all default values. This is guaranteed by data_to_dataclass() associated with all classes derived from the PyOED base configurations class PyOEDConfigs. Second, the aggregated configurations object configs is passed to the super class which in this case is PyOEDObject`. The __init__ code of PyOEDObject` is responsible for validating the configurations and assigning the configurations object to the right attribute (i.e., _CONFIGURATIONS).

The object PyOEDDummyObject now can be instantiated and used properly. However, the validation process will only validate the configurations keys/attributes defined by PyOEDConfigs which is the configurations class associated with the parent of PyOEDObject. By inspecting the source code of validate_configurations(), we see that verbose, debug, output_dir are only validated by calling the pyoed.configs.configs.validate_configurations() function. This needs to be implemented in PyOEDDummyObject so that all configurations are properly validated. This is achieved by updating the definition of PyOEDDummyObject as follows

Define the the PyOEDDummyObject with proper validation#
from pyoed.configs import PyOEDObject, set_configurations, validate_key

@set_configurations(PyOEDDummyObjectConfigs)
class PyOEDDummyObject(PyOEDObject):

    def __init__(self, configs: dict | PyOEDDummyObjectConfigs | None = None) -> None:
        # Aggregate configurations and call super
        configs = self.configurations_class.data_to_dataclass(configs)

        super().__init__(configs)

    def validate_configurations(
        self,
        configs: dict | PyOEDDummyObjectConfigs,
        raise_for_invalid: bool = True,
    ) -> bool:
        # Fuse/agregate configs into current/default configurations
        aggregated_configs = self.aggregate_configurations(configs=configs, )

        ## Validation Stage (of local keys/attributes defined by ``PyOEDDummyObjectConfigs``)
        # `name`: string
        if not validate_key(
            current_configs=aggregated_configs,
            new_configs=configs,
            key="purpose",
            test=lambda v: isinstance(v, str),
            message=f"`name` argument` is of invalid type. Expected string. ",
            raise_for_invalid=raise_for_invalid,
        ):
            return False

        # `purpose`: string
        if not validate_key(
            current_configs=aggregated_configs,
            new_configs=configs,
            key="purpose",
            test=lambda v: isinstance(v, str),
            message=f"`purpose` argument` is of invalid type. Expected string. ",
            raise_for_invalid=raise_for_invalid,
        ):
            return False


        ## Call and return super's validation to validate configurations in parent class
        return super().validate_configurations(configs, raise_for_invalid)

Thus, new objects implemented in PyOED should be associated with validate_configurations method that has similar signature to pyoed.configs.configs.PyOEDObject.validate_configurations() where the new code sandwiches validation (by calling pyoed.configs.configs.validate_key()) of new attributes defined by the configurations class.

After that, the PyOEDDummyObject can be instantiated in multiple ways as follows

Create PyOEDDummyObject with default configurations#
obj = PyOEDDummyObject()
print(obj.configurations)

which results in the following output:

**************************************************
  Configurations of: `PyOEDDummyObjectConfigs`
**************************************************
  >> debug (type <class 'bool'>): False
  >> verbose (type <class 'bool'>): False
  >> output_dir (type <class 'str'>): './_PYOED_RESULTS_'
  >> name (type <class 'str'>): 'A PyOED Dummy Object'
  >> purpose (type <class 'str'>): 'Teaching how to create PyOED objects'
**************************************************

this means that if one calls obj.configurations property/attribute, the result would be

PyOEDDummyObjectConfigs(debug=False, verbose=False, output_dir='./_PYOED_RESULTS_', name='A PyOED Dummy Object', purpose='Teaching how to create PyOED objects')

Now, to create the object with modified configurations, one can pass a dictionary (or instance of PyOEDDummyObjectConfigs) with desired full or partial configurations to the PyOEDDummyObject upon instantiation as follows:

Create PyOEDDummyObject with modified configurations#
obj = PyOEDDummyObject(
    {
        "name": "Modified name",
        "verbose": True,
    }
)
print(obj.configurations)

which would result in

**************************************************
  Configurations of: `PyOEDDummyObjectConfigs`
**************************************************
  >> debug (type <class 'bool'>): False
  >> verbose (type <class 'bool'>): True
  >> output_dir (type <class 'str'>): './_PYOED_RESULTS_'
  >> name (type <class 'str'>): 'Modified name'
  >> purpose (type <class 'str'>): 'Teaching how to create PyOED objects'
**************************************************

Of course, one can call the method PyOEDDummyObject.update_configurations to update any of the attributes of the associated configurations object. This method is inherited from pyoed.configs.configs.PyOEDObject.update_configurations() and it automatically aggregates passed keys with existing/default confiurations and runs validation before updating the attribute values.


Summary

In summary, here is the full code for creating PyOEDDummyObject and the associated PyOEDDummyObjectConfigs described above.

Full code of PyOEDDummyObject and PyOEDDummyObjectConfigs#
from dataclasses import dataclass
from pyoed.configs import (
    PyOEDObject,
    PyOEDConfigs,
    set_configurations,
    validate_key,
)

@dataclass(kw_only=True, slots=True)
class PyOEDDummyObjectConfigs(PyOEDConfigs):

    name: str = "A PyOED Dummy Object"
    purpose: str = "Teaching how to create PyOED objects"


@set_configurations(PyOEDDummyObjectConfigs)
class PyOEDDummyObject(PyOEDObject):

    def __init__(self, configs: dict | PyOEDDummyObjectConfigs | None = None) -> None:
        # Aggregate configurations and call super
        configs = self.configurations_class.data_to_dataclass(configs)

        super().__init__(configs)

    def validate_configurations(
        self,
        configs: dict | PyOEDDummyObjectConfigs,
        raise_for_invalid: bool = True,
    ) -> bool:
        # Fuse/agregate configs into current/default configurations
        aggregated_configs = self.aggregate_configurations(configs=configs, )

        ## Validation Stage (of local keys/attributes defined by ``PyOEDDummyObjectConfigs``)
        # `name`: string
        if not validate_key(
            current_configs=aggregated_configs,
            new_configs=configs,
            key="purpose",
            test=lambda v: isinstance(v, str),
            message=f"`name` argument` is of invalid type. Expected string. ",
            raise_for_invalid=raise_for_invalid,
        ):
            return False

        # `purpose`: string
        if not validate_key(
            current_configs=aggregated_configs,
            new_configs=configs,
            key="purpose",
            test=lambda v: isinstance(v, str),
            message=f"`purpose` argument` is of invalid type. Expected string. ",
            raise_for_invalid=raise_for_invalid,
        ):
            return False


        ## Call and return super's validation to validate configurations in parent class
        return super().validate_configurations(configs, raise_for_invalid)