Problem explanation
Overview
The OptProblem and EvaluatorInfo classes provide an user with the ability to define an optimization problem or the interface for an evaluator using different variable types and automated validation and filling in of information automatically that the user does not provide. That is, the classes will allow an user to define the minimum information required when instantiating them.
Key Features
Error Handling and Validation
The project incorporates error handling mechanisms and validation to ensure data consistency and correctness. Pydantic validators are used to enforce constraints on the data model, and try-except blocks are implemented in extraction functions to handle potential issues gracefully.
Documentation
Comprehensive documentation is provided using Sphinx, including detailed descriptions of the data classes, their attributes, and the extraction functions. This documentation facilitates understanding and usage of the data model, making it easier for users to integrate it into their workflows.
import numpy as np
from pydantic import BaseModel, Field, field_validator
from pydantic import model_validator
from typing import List, Optional, Dict, Tuple, Union, Literal
"""
Definition of optimization problems and evaluator information Pydantic classes.
Created Nov. 14, 2024
@author Joerg Gablonsky
"""
import re
import typing
import numpy as np
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import List, Optional, Dict, Tuple, Union, Literal
from numpydantic import NDArray, Shape
# Generating names of array variables
# We want to generate variable / response names that reflect that we have an array.
def generate_names(name: str, shape: tuple) -> typing.List[str]:
"""Generate names for variables / responses that reflect that they are arrays
The names generated use NumPy array syntax. For example, a variable input that
is a 1-d array of length 2 (shape (2,)) will generate this list:
['input[0]', 'input[1]']
Parameters
----------
name : str
Name of the variable or response
shape : tuple
Shape of the variable or response. This is a NumPy shape
Returns
-------
list[str]
List of all the variables / responses represented by the array
Raises
------
TypeError
If the name is not a string, or the shape is not a tuple
"""
if not isinstance(name, str):
raise TypeError("Name is not a string")
if not isinstance(shape, tuple):
raise TypeError("Shape is not a tuple")
new_names = []
for idx in np.ndindex(shape):
new_names.append(f"{name}[" + ",".join([str(value) for value in idx]) + "]")
return new_names
MAXINT = 2**63
class FloatVariable(BaseModel):
name: str = Field(description="Name of the variable.")
default: Optional[float] = Field(
default=None, description="Default value for this variable"
)
bounds: Optional[Tuple[float, float]] = Field(
default=(-np.inf, np.inf), description="Lower and upper bounds of the variable"
)
shift: Optional[float] = Field(
default=0.0,
description="NumPy array with the shift values to be used for the array variable.",
)
scale: Optional[float] = Field(
default=1.0,
description="NumPy array with the scale values to be used for the array variable. "
+ "Cannot be zero.",
)
unit: Optional[str] = None
description: Optional[str] = Field(
default="",
description="A description of the variable.",
)
class_type: Literal["float"] = Field(default="float", description="Class marker")
@field_validator("bounds")
def validate_bounds(cls, var):
if var is None:
var = (-np.inf, np.inf)
elif var[0] > var[1]:
raise ValueError(
f"Lower bounds are larger than upper bounds: {var[0]} > {var[1]}"
)
return var
@field_validator("scale")
def validate_scale(cls, scale):
if scale == 0:
raise ValueError(f"Scale cannot be 0.")
return scale
@field_validator('name')
def check_name(cls, name : str) -> str:
# We want to make sure the following checks are only done when
# the name is not an empty string
if len(name) > 0:
if re.search('\s', name):
raise ValueError(f"Name cannot contain white spaces.")
return name
class IntVariable(FloatVariable):
default: Optional[int] = Field(
default=None, description="Default value for this variable"
)
bounds: Optional[Tuple[int, int]] = Field(
default=(-MAXINT, MAXINT), description="Lower and upper bounds of the variable"
)
shift: Optional[int] = Field(
default=0,
description="NumPy array with the shift values to be used for the array variable.",
)
scale: Optional[int] = Field(
default=1,
description="NumPy array with the scale values to be used for the array variable. "
+ "Cannot be zero.",
)
class_type: Literal["int"] = Field(default="int", description="Class marker")
@field_validator("bounds")
def validate_bounds(cls, var):
if var is None:
var = (-MAXINT, MAXINT)
elif var[0] > var[1]:
raise ValueError(
f"Lower bounds are larger than upper bounds: {var[0]} > {var[1]}"
)
return var
class ArrayVariable(FloatVariable):
"""Class defining array variables. The underlying data type are NumPy float64 arrays
Raises:
ValueError: Lower bounds greater than upper bounds for at least one element
ValueError: Neither shape or default are set
ValueError: Scale for at least one element is set to 0.
ValueError: Inconsistent shapes for elements in the class
"""
shape: Optional[Tuple[int, ...]] = Field(
default=None, description="Shape of the arrays"
)
default: Optional[NDArray[Shape["*,..."], np.float64]] = Field(
default=None,
description="NumPy array with the default values to be used for the array variable",
)
bounds: Optional[
Tuple[
Union[float, int, NDArray[Shape["*,..."], np.float64]],
Union[float, int, NDArray[Shape["*,..."], np.float64]],
]
] = Field(default=(None, None), description="Lower and upper bounds of the arrays")
shift: Optional[Union[float, int, NDArray[Shape["*,..."], np.float64]]] = Field(
default=None,
description="NumPy array with the shift values to be used for the array variable.",
)
scale: Optional[Union[float, int, NDArray[Shape["*,..."], np.float64]]] = Field(
default=None,
description="NumPy array with the scale values to be used for the array variable. "
+ "Cannot be zero.",
)
class_type: Literal['floatarray'] = 'floatarray'
@field_validator("bounds")
def validate_bounds(cls, var):
if var is None:
# If bounds are not defined we don't need to do anything
return var
if np.any(var[0] > var[1]):
raise ValueError(
f"Lower bounds are larger than upper bounds: {var[0]} > {var[1]}"
)
return var
@field_validator("scale")
def validate_scale(cls, scale):
# Because we only want to do this validation once we have a shape defined it
# happens in the check_shape method
return scale
def _expand(self, value, scaler=1.0):
if value is not None:
if isinstance(value, (float, int)):
value = np.ones(self.shape) * value
else:
value = np.ones(self.shape) * scaler
return value
@model_validator(mode="after")
def check_shape(self):
shape = self.shape
default = self.default
shift = self.shift
scale = self.scale
if shape is None:
# If no shape is defined default must be set. If default is
# set it's shape will define the shape. If neither is set we throw an error
if default is not None:
shape = default.shape
else:
raise ValueError(
f"Shape for this variable is not set, and neither are default or value"
)
self.shape = shape
# If shift is set we want to check if only a single value is set. If that is the
# case we expand it to the full shape.
shift = self._expand(shift, 0.0)
self.shift = shift
# If scale is set we want to check if only a single value is set. If that is the
# case we expand it to the full shape.
scale = self._expand(scale)
# We have to make sure none of the values of scale are 0.
if np.any(scale == 0.0):
raise ValueError(f"Scale for one element in the matrix is set to 0.0")
self.scale = scale
# Check that the bounds are set correctly
(low_bound, up_bound) = self.bounds
low_bound = self._expand(low_bound, -np.inf)
up_bound = self._expand(up_bound, np.inf)
self.bounds = (low_bound, up_bound)
fields = [default, shift, scale, low_bound, up_bound]
field_names = ["default", "shift", "scale", "lower bound", "upper bound"]
# Check if default is not None. If default is set we check that the shape is
# set correctly.
for local_field, field_name in zip(fields, field_names):
if local_field is not None:
if local_field.shape != shape:
raise ValueError(
f"Shape for this variable is {shape}, but {field_name} has shape {local_field.shape}"
)
return self
def unique_names(var):
names = []
for my_var in var:
names.append(my_var.name)
if len(names) != len(set(names)):
dup = {x for x in names if names.count(x) > 1}
raise ValueError(
f"There is at least one variable / response name that is used multiple times. Names are {dup}"
)
# Define the Union of the different variable types. Note that we use that for responses as well
Variable = Union[IntVariable, FloatVariable, ArrayVariable]
class EvaluatorInfo(BaseModel):
"""
Represents information about an evaluator.
Args:
name: The name of the evaluator.
variables: A list of variable objects.
tool: Tool used for the evaluator.
inputs: Input variables.
outputs: Output variables.
evaluator_identifier: Unique identifier of the evaluator.
version: Version of the evaluator.
description: Description of the evaluator.
cite: Listing of relevant citations that should be referenced when publishing work that uses this class.
component_type: Component type (ExplicitComponent, ImplicitComponent, Group, etc.).
options: Additional options for the component.
"""
name: str = Field(description="The name of the evaluator")
class_type: Literal["EvaluatorInfo"] = "EvaluatorInfo"
inputs: List[Variable] = Field(description="Input variables")
outputs: List[Variable] = Field(description="Output variables")
description: Optional[str] = Field(
default=None,
description="A description of the optimization problem. To define mathematical symbols use markdown syntax.",
)
cite: Optional[str] = Field(
default=None,
description="Listing of relevant citations that should be referenced when publishing work that uses this class.",
)
tool: Optional[str] = Field(default=None, description="Name of the tool wrapped")
evaluator_identifier: Optional[str] = Field(
default=None, description="Unique identifier for the evaluator."
)
version: Optional[str] = Field(
default=None, description="Version of the evaluator."
)
component_type: Optional[str] = Field(
default=None,
description="Component type (ExplicitComponent, ImplicitComponent, Group, etc.).",
)
options: Dict = Field(
default_factory=dict, description="Additional options for the problem."
)
@field_validator("inputs", "outputs")
def validate_outputs(cls, var):
unique_names(var)
return var
class OptProblem(BaseModel):
"""
Represents information about an optimization problem.
Args:
name: The name of the problem. Defaults to 'opt_problem'
variables: A list of variable objects.
tool: Tool used for the evaluator.
inputs: Input variables.
outputs: Output variables.
evaluator_identifier: Unique identifier of the evaluator.
version: Version of the evaluator.
description: Description of the evaluator.
cite: Listing of relevant citations that should be referenced when publishing work that uses this class.
component_type: Component type (ExplicitComponent, ImplicitComponent, Group, etc.).
options: Additional options for the component.
"""
name: str = Field(default='opt_problem', description='The name of the problem. Defaults to "opt_problem"')
class_type: Literal['OptProblem'] = 'OptProblem'
variables: List[Variable] = Field(description="Input variables")
responses: List[Variable] = Field(description="Output variables")
objectives: List[str] = Field(default_factory=list, description="Names of the objective(s) for the optimization problem. Must be either variables or responses defined in the problem.")
constraints: List[str] = Field(default_factory=list, description="Names of the constraints of the optimization problem. Must be responses defined in the problem. To define bounds on variables use the variable bounds.")
description: Optional[str] = Field(default=None, description="A description of the optimization problem. To define mathematical symbols use markdown syntax.")
cite: Optional[str] = Field(
default=None,
description="Listing of relevant citations that should be referenced when publishing work that uses this class.",
)
options: Dict = Field(default_factory=dict, description="Additional options for the problem.")
@field_validator('variables', 'responses')
def validate_outputs(cls, var):
unique_names(var)
return var
def unroll_names(self, elements : List[Variable]) -> List[str]:
all_names = []
for local_element in elements:
name = local_element.name
if isinstance(local_element, ArrayVariable):
array_names = generate_names(name, local_element.shape)
all_names += array_names
else:
all_names.append(name)
return(all_names)
@model_validator(mode='after')
def check_problem(self):
# This method needs to be updated to allow using an element from a FloatArray
variable_names = self.unroll_names(self.variables)
response_names = self.unroll_names(self.responses)
elements = set(variable_names + response_names)
if self.objectives is not None:
# Check if all the objectives are either a variable or response
for name in self.objectives:
if name not in elements:
raise ValueError(f"{name} is defined as an objective, but not defined as a variable or response.")
if self.constraints is not None:
# Check if all the objectives are either a variable or response
for name in self.constraints:
if name not in response_names:
if name in variable_names:
raise ValueError(f"{name} is defined as a constraint, but defined as a variable. Please define bounds on the variable itself. Constraints should only be responses.")
raise ValueError(f"{name} is defined as a constraint, but not defined as a response.")
return self
Defining variables
We can define individual variables by instantiating the appropriate variable instances. Currently we are supporting the following types:
FloatVariable: The basic floating point variableIntVariable: An integer variableArrayVariable: A variable that contains an array of 64-bit floating points of any dimension
Note that we could add additional array variables, for example to store integer values, or lower precision integer or floating point values.
We define a general variable with the Variable type, which is a union of the above classes.
For each variable we support several fields:
name: The name of the variable. Note that the name cannot containt white spaces.default: The default value of the variable. If this is an array variable and a user sets this, the shape of the variable is defined by the shape of hedefaultfield. Otherwise the user needs to define the shape of the variable by using theshapefield that is unique to theArrayVariable.bounds: Lower and upper bounds for the variable. We enforce that the lower bound cannot be larger than the upper bound (equal is OK).shift: The amount by which this variable should be shifted to create a shifted version of the point. Mostly used in conjunction with optimization problems.scale: The amount by which this variable should be scaled to create a scaled version of the point. Mostly used in conjunction with optimization problems. Must be non-zero.unit: The unit for this variable.description: This field allows the user to provide a description about this variable.class_type: This is a fixed field that the system uses for serialization and when generating a variable from a dictionary.
Examples
Variables
We start by showing how to generate ArrayVariable in different ways. The first example defines a 1x2 matrix and sets it default value and defines both lower and upper bounds. Noter that for the lower bounds we only need to pass in a single valuer, and the system then expands it to an array of the right shape with that value in it.
The second examples shows an ArrayVariable that is a floating point vector with defined default value (which sets the shape of the variable), and sets the scaling factor to be 9 for each elements of the vector.
When you print these two different instances you can see that bounds are automatically set, as are the shift and scaling values. Note that for the second variable the scale is 9 for each element.
default = np.array([[3.2, 4.23]])
print(1, ArrayVariable(name='x1', default=default, shape=default.shape, bounds=(-4, default)))
default = np.array([3.2, 223.4 , 3345., 8.7])
x8 = ArrayVariable(name='x8', default=default, scale = 9)
print(2, x8)
1 name='x1' default=array([[3.2 , 4.23]]) bounds=(array([[-4., -4.]]), array([[3.2 , 4.23]])) shift=array([[0., 0.]]) scale=array([[1., 1.]]) unit=None description='' class_type='floatarray' shape=(1, 2)
2 name='x8' default=array([3.200e+00, 2.234e+02, 3.345e+03, 8.700e+00]) bounds=(array([-inf, -inf, -inf, -inf]), array([inf, inf, inf, inf])) shift=array([0., 0., 0., 0.]) scale=array([9., 9., 9., 9.]) unit=None description='' class_type='floatarray' shape=(4,)
In the next examples we show how to define an integer variable, and how to create a one element vector variable. We also show that an error is thrown when you try to set the scale to 0. If an element of the variable is modified we can run the Pydantic method model_validate to make sure the models are valid. We also show that the prevention of setting scale to 0 works for the floating point variables.
We provide another example of defining an array variable and end this section with creating a floating point variable by only defining the name of it.
x3 =IntVariable(name='x3', default=4, bounds=[2,5])
print(3, x3)
default = np.array([3.2, ])
x4 =ArrayVariable(name='x4', default=default, scale=np.array([2.3]))
print(4, x4)
try:
x4 =ArrayVariable(name='x4', default=default, scale=np.array([0.0]))
except Exception as error:
print(f"As expected, we get an error: {error}")
try:
x4.default = 'hello'
x4.model_validate(x4)
except Exception as error:
print(f"As expected, we get an error: {error}")
try:
x4 =FloatVariable(name='x4', scale=0)
except Exception as error:
print(f"As expected, we get an error: {error}")
x9 = ArrayVariable.parse_obj({'name':"x9", 'default':np.array([243.5])})
print(x9)
y1 = FloatVariable(name="y1")
print(y1)
3 name='x3' default=4 bounds=(2, 5) shift=0 scale=1 unit=None description='' class_type='int'
4 name='x4' default=array([3.2]) bounds=(array([-inf]), array([inf])) shift=array([0.]) scale=array([2.3]) unit=None description='' class_type='floatarray' shape=(1,)
As expected, we get an error: 1 validation error for ArrayVariable
Value error, Scale for one element in the matrix is set to 0.0 [type=value_error, input_value={'name': 'x4', 'default':...), 'scale': array([0.])}, input_type=dict]
For further information visit https://errors.pydantic.dev/2.13/v/value_error
As expected, we get an error: 'str' object has no attribute 'shape'
As expected, we get an error: 1 validation error for FloatVariable
scale
Value error, Scale cannot be 0. [type=value_error, input_value=0, input_type=int]
For further information visit https://errors.pydantic.dev/2.13/v/value_error
name='x9' default=array([243.5]) bounds=(array([-inf]), array([inf])) shift=array([0.]) scale=array([1.]) unit=None description='' class_type='floatarray' shape=(1,)
name='y1' default=None bounds=(-inf, inf) shift=0.0 scale=1.0 unit=None description='' class_type='float'
/tmp/ipykernel_2304/2955237916.py:23: PydanticDeprecatedSince20: The `parse_obj` method is deprecated; use `model_validate` instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/
x9 = ArrayVariable.parse_obj({'name':"x9", 'default':np.array([243.5])})
Evaluator Information
The next class we will be discussing is the EvaluatorInfo class which allows us to capture information about an Evaluator. The purpose of this class is to capture the information about an evaluator, and make it easy to exchange that information, including serialization of it. In Design Explorer we will use this class to help with passing information to an evaluator, and also use this to capture all information about the evaluator for serialization. We will use the same format in the NASA MBSA&E contract for capturing the information about elements in an assembly, including the full assembly.
Below we show the properties of this class, and a short description of all properties that is part of the class definition.
for name, info in EvaluatorInfo.schema()['properties'].items():
if 'description' in info:
print(f"{name}: {info['description']}")
name: The name of the evaluator
inputs: Input variables
outputs: Output variables
description: A description of the optimization problem. To define mathematical symbols use markdown syntax.
cite: Listing of relevant citations that should be referenced when publishing work that uses this class.
tool: Name of the tool wrapped
evaluator_identifier: Unique identifier for the evaluator.
version: Version of the evaluator.
component_type: Component type (ExplicitComponent, ImplicitComponent, Group, etc.).
options: Additional options for the problem.
/tmp/ipykernel_2304/627856710.py:1: PydanticDeprecatedSince20: The `schema` method is deprecated; use `model_json_schema` instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/
for name, info in EvaluatorInfo.schema()['properties'].items():
We expand those descriptions below:
name: The name of the evaluator. This required field will give the evaluator a name to be used when serializing and printing information.
inputs: Input variables. This list allows the definition of the inputs to the evaluator. We require that an evaluator has to have at least one input.
outputs: Output variables. This list allows the definition of the outputs of the evaluator. Note that this list can be empty since sometimes an evaluator might just save data or print things to a screen.
description: A description of the optimization problem. To define mathematical symbols use markdown syntax.
cite: Listing of relevant citations that should be referenced when publishing work that uses this class.
tool: Name of the tool wrapped.
evaluator_identifier: Unique identifier for the evaluator.
version: Version of the evaluator.
component_type: Component type (ExplicitComponent, ImplicitComponent, Group, etc.). We will define a number of standard types based on the OpenMDAO capabilities.
options: Additional options for the problem. This is a dictionary where additional information for the evaluator can be stored.
One thing that an evaluator might do is to enforce bounds on the variables that are defined for the inputs. That would mean that if an user tries to call the evaluator with inputs that are outside the bounds defined the evaluator would either return NaN as the value of the responses, or throw an error. Note that bounds on responses are ignored. And it is up to the implementation of the evaluator what to do in case inputs are out of bounds.
Examples
In the next cell we show some examples of defining an EvaluatorInfo instance. We first show how to create a basic instance. To make sure all calculated defaults are being set we run the initial instance through the model_validate method of Pydantic and store the validated model in the new_eval variable, and print out it’s state.
Next we show the effect of using the exclude_unset option when we create a dictionary describing the class and compare that to the more verbose output when leaving out that option.
Finally, we show that we can recreate an instance of that EvaluatorInfo class by using the Pydantic methods model_dump and parse_obj.
new_eval = EvaluatorInfo(name='bla', inputs=[
{'name': 'x0', 'default': np.array([1.3, 4.32])},
{'name':"x1", 'default':np.array([243.5])},
{'name':"x5", 'class_type': 'floatarray',
'default':np.array([22.5])}], outputs=[x8, x3])
new_eval = EvaluatorInfo.model_validate(new_eval)
print(new_eval)
print(new_eval.model_dump(exclude_unset=True))
print(new_eval.model_dump())
restore_eval = EvaluatorInfo.parse_obj(new_eval.model_dump(exclude_unset=True))
print(restore_eval)
name='bla' class_type='EvaluatorInfo' inputs=[ArrayVariable(name='x0', default=array([1.3 , 4.32]), bounds=(array([-inf, -inf]), array([inf, inf])), shift=array([0., 0.]), scale=array([1., 1.]), unit=None, description='', class_type='floatarray', shape=(2,)), ArrayVariable(name='x1', default=array([243.5]), bounds=(array([-inf]), array([inf])), shift=array([0.]), scale=array([1.]), unit=None, description='', class_type='floatarray', shape=(1,)), ArrayVariable(name='x5', default=array([22.5]), bounds=(array([-inf]), array([inf])), shift=array([0.]), scale=array([1.]), unit=None, description='', class_type='floatarray', shape=(1,))] outputs=[ArrayVariable(name='x8', default=array([3.200e+00, 2.234e+02, 3.345e+03, 8.700e+00]), bounds=(array([-inf, -inf, -inf, -inf]), array([inf, inf, inf, inf])), shift=array([0., 0., 0., 0.]), scale=array([9., 9., 9., 9.]), unit=None, description='', class_type='floatarray', shape=(4,)), IntVariable(name='x3', default=4, bounds=(2, 5), shift=0, scale=1, unit=None, description='', class_type='int')] description=None cite=None tool=None evaluator_identifier=None version=None component_type=None options={}
{'name': 'bla', 'inputs': [{'name': 'x0', 'default': array([1.3 , 4.32]), 'bounds': (array([-inf, -inf]), array([inf, inf])), 'shift': array([0., 0.]), 'scale': array([1., 1.]), 'shape': (2,)}, {'name': 'x1', 'default': array([243.5]), 'bounds': (array([-inf]), array([inf])), 'shift': array([0.]), 'scale': array([1.]), 'shape': (1,)}, {'name': 'x5', 'default': array([22.5]), 'bounds': (array([-inf]), array([inf])), 'shift': array([0.]), 'scale': array([1.]), 'class_type': 'floatarray', 'shape': (1,)}], 'outputs': [{'name': 'x8', 'default': array([3.200e+00, 2.234e+02, 3.345e+03, 8.700e+00]), 'bounds': (array([-inf, -inf, -inf, -inf]), array([inf, inf, inf, inf])), 'shift': array([0., 0., 0., 0.]), 'scale': array([9., 9., 9., 9.]), 'shape': (4,)}, {'name': 'x3', 'default': 4, 'bounds': (2, 5)}]}
{'name': 'bla', 'class_type': 'EvaluatorInfo', 'inputs': [{'name': 'x0', 'default': array([1.3 , 4.32]), 'bounds': (array([-inf, -inf]), array([inf, inf])), 'shift': array([0., 0.]), 'scale': array([1., 1.]), 'unit': None, 'description': '', 'class_type': 'floatarray', 'shape': (2,)}, {'name': 'x1', 'default': array([243.5]), 'bounds': (array([-inf]), array([inf])), 'shift': array([0.]), 'scale': array([1.]), 'unit': None, 'description': '', 'class_type': 'floatarray', 'shape': (1,)}, {'name': 'x5', 'default': array([22.5]), 'bounds': (array([-inf]), array([inf])), 'shift': array([0.]), 'scale': array([1.]), 'unit': None, 'description': '', 'class_type': 'floatarray', 'shape': (1,)}], 'outputs': [{'name': 'x8', 'default': array([3.200e+00, 2.234e+02, 3.345e+03, 8.700e+00]), 'bounds': (array([-inf, -inf, -inf, -inf]), array([inf, inf, inf, inf])), 'shift': array([0., 0., 0., 0.]), 'scale': array([9., 9., 9., 9.]), 'unit': None, 'description': '', 'class_type': 'floatarray', 'shape': (4,)}, {'name': 'x3', 'default': 4, 'bounds': (2, 5), 'shift': 0, 'scale': 1, 'unit': None, 'description': '', 'class_type': 'int'}], 'description': None, 'cite': None, 'tool': None, 'evaluator_identifier': None, 'version': None, 'component_type': None, 'options': {}}
name='bla' class_type='EvaluatorInfo' inputs=[ArrayVariable(name='x0', default=array([1.3 , 4.32]), bounds=(array([-inf, -inf]), array([inf, inf])), shift=array([0., 0.]), scale=array([1., 1.]), unit=None, description='', class_type='floatarray', shape=(2,)), ArrayVariable(name='x1', default=array([243.5]), bounds=(array([-inf]), array([inf])), shift=array([0.]), scale=array([1.]), unit=None, description='', class_type='floatarray', shape=(1,)), ArrayVariable(name='x5', default=array([22.5]), bounds=(array([-inf]), array([inf])), shift=array([0.]), scale=array([1.]), unit=None, description='', class_type='floatarray', shape=(1,))] outputs=[ArrayVariable(name='x8', default=array([3.200e+00, 2.234e+02, 3.345e+03, 8.700e+00]), bounds=(array([-inf, -inf, -inf, -inf]), array([inf, inf, inf, inf])), shift=array([0., 0., 0., 0.]), scale=array([9., 9., 9., 9.]), unit=None, description='', class_type='floatarray', shape=(4,)), IntVariable(name='x3', default=4, bounds=(2, 5), shift=0, scale=1, unit=None, description='', class_type='int')] description=None cite=None tool=None evaluator_identifier=None version=None component_type=None options={}
/tmp/ipykernel_2304/2954395982.py:10: PydanticDeprecatedSince20: The `parse_obj` method is deprecated; use `model_validate` instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/
restore_eval = EvaluatorInfo.parse_obj(new_eval.model_dump(exclude_unset=True))
In the next example we show that we can create an ArrayVariable instance with a default of a Python list of list, and the automatic conversion of that list of list to a NumPy array is happening. We again demonstrate the model validation, and the ability to recreate a new EvaluatorInfo instance from the serialization of the existing class.
my_np = ArrayVariable(name="x1", default=[[299.3, 243.5]])
print(my_np)
print("json:", my_np.json())
print(type(my_np.default))
my_eval = EvaluatorInfo(name="dummy", inputs=[my_np, FloatVariable(name="p2", class_type='float')], outputs=[FloatVariable(name="y2", class_type='float')])
# making sure all fields are updated and valid
my_eval = EvaluatorInfo.model_validate(my_eval)
print("before: ",my_eval.model_dump())
new_eval = EvaluatorInfo.parse_obj(my_eval.model_dump())
print(new_eval)
print(new_eval.inputs[0].bounds, type(new_eval.inputs[0].bounds[0]), new_eval.inputs[0].bounds[0].shape)
name='x1' default=array([[299.3, 243.5]]) bounds=(array([[-inf, -inf]]), array([[inf, inf]])) shift=array([[0., 0.]]) scale=array([[1., 1.]]) unit=None description='' class_type='floatarray' shape=(1, 2)
json: {"name":"x1","default":[[299.3,243.5]],"bounds":[[[null,null]],[[null,null]]],"shift":[[0.0,0.0]],"scale":[[1.0,1.0]],"unit":null,"description":"","class_type":"floatarray","shape":[1,2]}
<class 'numpy.ndarray'>
before: {'name': 'dummy', 'class_type': 'EvaluatorInfo', 'inputs': [{'name': 'x1', 'default': array([[299.3, 243.5]]), 'bounds': (array([[-inf, -inf]]), array([[inf, inf]])), 'shift': array([[0., 0.]]), 'scale': array([[1., 1.]]), 'unit': None, 'description': '', 'class_type': 'floatarray', 'shape': (1, 2)}, {'name': 'p2', 'default': None, 'bounds': (-inf, inf), 'shift': 0.0, 'scale': 1.0, 'unit': None, 'description': '', 'class_type': 'float'}], 'outputs': [{'name': 'y2', 'default': None, 'bounds': (-inf, inf), 'shift': 0.0, 'scale': 1.0, 'unit': None, 'description': '', 'class_type': 'float'}], 'description': None, 'cite': None, 'tool': None, 'evaluator_identifier': None, 'version': None, 'component_type': None, 'options': {}}
name='dummy' class_type='EvaluatorInfo' inputs=[ArrayVariable(name='x1', default=array([[299.3, 243.5]]), bounds=(array([[-inf, -inf]]), array([[inf, inf]])), shift=array([[0., 0.]]), scale=array([[1., 1.]]), unit=None, description='', class_type='floatarray', shape=(1, 2)), FloatVariable(name='p2', default=None, bounds=(-inf, inf), shift=0.0, scale=1.0, unit=None, description='', class_type='float')] outputs=[FloatVariable(name='y2', default=None, bounds=(-inf, inf), shift=0.0, scale=1.0, unit=None, description='', class_type='float')] description=None cite=None tool=None evaluator_identifier=None version=None component_type=None options={}
(array([[-inf, -inf]]), array([[inf, inf]])) <class 'numpy.ndarray'> (1, 2)
/tmp/ipykernel_2304/101665509.py:3: PydanticDeprecatedSince20: The `json` method is deprecated; use `model_dump_json` instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/
print("json:", my_np.json())
/tmp/ipykernel_2304/101665509.py:12: PydanticDeprecatedSince20: The `parse_obj` method is deprecated; use `model_validate` instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/
new_eval = EvaluatorInfo.parse_obj(my_eval.model_dump())
Optimization Problem
The next core class is describing an actual optimization problem. Often this will be created by taking information from an evaluator via the EvaluatorInfo class to define variables and responses, but we set this up that an user can defined an optimization problem independently of an evaluator.
Below we show all the properties of the OptProblem class, and basic description of those.
for name, info in OptProblem.schema()['properties'].items():
if 'description' in info:
print(f"{name}: {info['description']}")
name: The name of the problem. Defaults to "opt_problem"
variables: Input variables
responses: Output variables
objectives: Names of the objective(s) for the optimization problem. Must be either variables or responses defined in the problem.
constraints: Names of the constraints of the optimization problem. Must be responses defined in the problem. To define bounds on variables use the variable bounds.
description: A description of the optimization problem. To define mathematical symbols use markdown syntax.
cite: Listing of relevant citations that should be referenced when publishing work that uses this class.
options: Additional options for the problem.
/tmp/ipykernel_2304/3952658757.py:1: PydanticDeprecatedSince20: The `schema` method is deprecated; use `model_json_schema` instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/
for name, info in OptProblem.schema()['properties'].items():
We expand those descriptions below:
name: The name of the optimization problem. This field will give the evaluator a name to be used when serializing and printing information. Defaults to ‘opt_problem’
variables: Input variables for this optimization problem. At least one variable must be defined.
responses: Output variables for this optimization problem. At least one response needs to be defined.
objectives: Names of the objective(s) for the optimization problem. Must be either variables or - responses defined in the problem.
constraints: Names of the constraints of the optimization problem. Must be responses defined in the problem. To define bounds on variables use the variable bounds.
description: A description of the optimization problem. To define mathematical symbols use markdown syntax.
cite: Listing of relevant citations that should be referenced when publishing work that uses this class.
options: Additional options for the problem.
Note that when defining objectives or constraints that involve array variables the user can define a specific element of the variable to be an objective or constraint via normal Python array syntax, that is if the variable reach is a two-dimensional array and the user wants to use the third element of the second row as the objective they would use reach[1,2] as the objective.
Examples
Below we show an example of an optimization problem that we generate from scratch.
After creating the instance we also demonstrate how to get information for each of the properties.
my_np = ArrayVariable(name="x1", default=[[299.3, 243.5]])
print(my_np)
my_eval = OptProblem(variables=[my_np, FloatVariable(name="p2")], responses=[FloatVariable(name="y2")], objectives=["y2"], constraints=["y2"])
print("before: ",my_eval.model_dump())
for k, v in my_eval.model_fields.items():
print(f"{k}: {v}")
name='x1' default=array([[299.3, 243.5]]) bounds=(array([[-inf, -inf]]), array([[inf, inf]])) shift=array([[0., 0.]]) scale=array([[1., 1.]]) unit=None description='' class_type='floatarray' shape=(1, 2)
before: {'name': 'opt_problem', 'class_type': 'OptProblem', 'variables': [{'name': 'x1', 'default': array([[299.3, 243.5]]), 'bounds': (array([[-inf, -inf]]), array([[inf, inf]])), 'shift': array([[0., 0.]]), 'scale': array([[1., 1.]]), 'unit': None, 'description': '', 'class_type': 'floatarray', 'shape': (1, 2)}, {'name': 'p2', 'default': None, 'bounds': (-inf, inf), 'shift': 0.0, 'scale': 1.0, 'unit': None, 'description': '', 'class_type': 'float'}], 'responses': [{'name': 'y2', 'default': None, 'bounds': (-inf, inf), 'shift': 0.0, 'scale': 1.0, 'unit': None, 'description': '', 'class_type': 'float'}], 'objectives': ['y2'], 'constraints': ['y2'], 'description': None, 'cite': None, 'options': {}}
name: annotation=str required=False default='opt_problem' description='The name of the problem. Defaults to "opt_problem"'
class_type: annotation=Literal['OptProblem'] required=False default='OptProblem'
variables: annotation=List[Union[IntVariable, FloatVariable, ArrayVariable]] required=True description='Input variables'
responses: annotation=List[Union[IntVariable, FloatVariable, ArrayVariable]] required=True description='Output variables'
objectives: annotation=List[str] required=False default_factory=list description='Names of the objective(s) for the optimization problem. Must be either variables or responses defined in the problem.'
constraints: annotation=List[str] required=False default_factory=list description='Names of the constraints of the optimization problem. Must be responses defined in the problem. To define bounds on variables use the variable bounds.'
description: annotation=Union[str, NoneType] required=False default=None description='A description of the optimization problem. To define mathematical symbols use markdown syntax.'
cite: annotation=Union[str, NoneType] required=False default=None description='Listing of relevant citations that should be referenced when publishing work that uses this class.'
options: annotation=Dict required=False default_factory=dict description='Additional options for the problem.'
/tmp/ipykernel_2304/2010609397.py:7: PydanticDeprecatedSince211: Accessing the 'model_fields' attribute on the instance is deprecated. Instead, you should access this attribute from the model class. Deprecated in Pydantic V2.11 to be removed in V3.0.
for k, v in my_eval.model_fields.items():
The last example shows how we can define an optimization problem as simply as possible by using dictionaries and letting types of variables be defined implicitly by the type of the default value.
x0is an integer variable, and the type is defined via the default valuex1is a floating point variable since we explicitly set theclass_typeto float.x2is a floating point variable, and the type is defined via the default value. Even though the default value is a one element one dimensional Numpy array it is set a float since the default value can be translated into a float.x4sets the variable type to a float array, and therefore is a float array even though it’s default value is also a one element one dimensional NumPy array.x5is a minimal float variable.
new_prob = OptProblem(variables=[{'name': 'x0', 'default': 1},
{'name': 'x1', 'default': 1, 'class_type': 'float'},
{'name':"x2", 'default':np.array([243.5])},
{'name':"x4", 'class_type': 'floatarray', 'default':np.array([22.5])},
{'name': 'x5', 'default': 1.},
{'name': 'x6', 'default': np.array([[3.2, 4.2, 6.2], [7.2, 5.3, 6.3]])},
{'name': 'x12', 'shape': (3,4,5)},
],
responses=[x8, x3], objectives=['x6[1,2]'])
new_prob.model_dump()
{'name': 'opt_problem',
'class_type': 'OptProblem',
'variables': [{'name': 'x0',
'default': 1,
'bounds': (-9223372036854775808, 9223372036854775808),
'shift': 0,
'scale': 1,
'unit': None,
'description': '',
'class_type': 'int'},
{'name': 'x1',
'default': 1.0,
'bounds': (-inf, inf),
'shift': 0.0,
'scale': 1.0,
'unit': None,
'description': '',
'class_type': 'float'},
{'name': 'x2',
'default': array([243.5]),
'bounds': (array([-inf]), array([inf])),
'shift': array([0.]),
'scale': array([1.]),
'unit': None,
'description': '',
'class_type': 'floatarray',
'shape': (1,)},
{'name': 'x4',
'default': array([22.5]),
'bounds': (array([-inf]), array([inf])),
'shift': array([0.]),
'scale': array([1.]),
'unit': None,
'description': '',
'class_type': 'floatarray',
'shape': (1,)},
{'name': 'x5',
'default': 1.0,
'bounds': (-inf, inf),
'shift': 0.0,
'scale': 1.0,
'unit': None,
'description': '',
'class_type': 'float'},
{'name': 'x6',
'default': array([[3.2, 4.2, 6.2],
[7.2, 5.3, 6.3]]),
'bounds': (array([[-inf, -inf, -inf],
[-inf, -inf, -inf]]),
array([[inf, inf, inf],
[inf, inf, inf]])),
'shift': array([[0., 0., 0.],
[0., 0., 0.]]),
'scale': array([[1., 1., 1.],
[1., 1., 1.]]),
'unit': None,
'description': '',
'class_type': 'floatarray',
'shape': (2, 3)},
{'name': 'x12',
'default': None,
'bounds': (array([[[-inf, -inf, -inf, -inf, -inf],
[-inf, -inf, -inf, -inf, -inf],
[-inf, -inf, -inf, -inf, -inf],
[-inf, -inf, -inf, -inf, -inf]],
[[-inf, -inf, -inf, -inf, -inf],
[-inf, -inf, -inf, -inf, -inf],
[-inf, -inf, -inf, -inf, -inf],
[-inf, -inf, -inf, -inf, -inf]],
[[-inf, -inf, -inf, -inf, -inf],
[-inf, -inf, -inf, -inf, -inf],
[-inf, -inf, -inf, -inf, -inf],
[-inf, -inf, -inf, -inf, -inf]]]),
array([[[inf, inf, inf, inf, inf],
[inf, inf, inf, inf, inf],
[inf, inf, inf, inf, inf],
[inf, inf, inf, inf, inf]],
[[inf, inf, inf, inf, inf],
[inf, inf, inf, inf, inf],
[inf, inf, inf, inf, inf],
[inf, inf, inf, inf, inf]],
[[inf, inf, inf, inf, inf],
[inf, inf, inf, inf, inf],
[inf, inf, inf, inf, inf],
[inf, inf, inf, inf, inf]]])),
'shift': array([[[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]],
[[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]],
[[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.],
[0., 0., 0., 0., 0.]]]),
'scale': array([[[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.]],
[[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.]],
[[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.]]]),
'unit': None,
'description': '',
'class_type': 'floatarray',
'shape': (3, 4, 5)}],
'responses': [{'name': 'x8',
'default': array([3.200e+00, 2.234e+02, 3.345e+03, 8.700e+00]),
'bounds': (array([-inf, -inf, -inf, -inf]), array([inf, inf, inf, inf])),
'shift': array([0., 0., 0., 0.]),
'scale': array([9., 9., 9., 9.]),
'unit': None,
'description': '',
'class_type': 'floatarray',
'shape': (4,)},
{'name': 'x3',
'default': 4,
'bounds': (2, 5),
'shift': 0,
'scale': 1,
'unit': None,
'description': '',
'class_type': 'int'}],
'objectives': ['x6[1,2]'],
'constraints': [],
'description': None,
'cite': None,
'options': {}}