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):
Special Attributes/Variables:
_CONFIGURATIONS_CLASS: a class-level variable/attribute that defines the dataclass derived fromPyOEDConfigswhich is used to create a configurations object. For the basePyOEDObject, this is assignedPyOEDConfigs._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 callingaggregate_configurations()upon instantiation. See instantiation steps below for more details._HAS_VALIDATION: bool that defines (automatically detected) whether the class has an implementedvalidate_configurations()method or not
Methods:
get_configurations_class(): refers to_CONFIGURATIONS_CLASSget_default_configurations(): creates an instance of the configurations class (_CONFIGURATIONS_CLASS) with default values
Properties:
configurations: refers to_CONFIGURATIONSconfigurations_class: equivalent to (wrapper around)get_configurations_class()default_configurations: equivalent to (wrapper around)get_default_configurations()verbose: bool that turns on/off screen verbosity of the class/object (usage of this attribute is up to the developer).debug: bool that turns on/off debug mode (ish) of the class/object (likeverboseusage of this attribute is up to the developer).
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:
The object’s class name is
PyOEDDummyObjectand is expected to inherit thePyOEDObjectwith the following additional configuration keys/attributes:name: string representation of the name of the object
verbsoe: boolean describing screen verbosity
purpose: string describing the objective of the object
The object has a method named
show_purposewhich prints out the name and the purpose of the object.
Three main steps, the user should follow:
Extend the base configurations and define any new configuration attributes as needed
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.)
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.
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
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#
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
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
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:
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.
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)