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 fromPyOEDConfigs
which 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_CLASS
get_default_configurations()
: creates an instance of the configurations class (_CONFIGURATIONS_CLASS
) with default values
Properties:
configurations
: refers to_CONFIGURATIONS
configurations_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 (likeverbose
usage 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
PyOEDDummyObject
and is expected to inherit thePyOEDObject
with 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_purpose
which 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)