damar-wicaksono / uqtestfuns

A Python3 library of test functions from the uncertainty quantification community with a common interface for validation and benchmarking purposes.
https://uqtestfuns.readthedocs.io/en/latest/
MIT License
8 stars 0 forks source link

Refactor How UQTestFuns Should Handle Parameters #351

Open damar-wicaksono opened 2 months ago

damar-wicaksono commented 2 months ago

Currently, arbitrary parameters may be assigned to a property of UQTestFuns instances. If the underlying function supports it, then they will be consumed accordingly. The responsibility about how to consume the parameters is delegated to the actual implementation of the test function (i.e., inside the respective module). Users may change the value of the parameters directly on the instance.

However, the current implementation is not safe as there is no guarantee that user will supply the correct parameters, in particular, their types as a value of the parameter can be anything. Semantically, the parameters being arbitrary has no structure from one test function implementation to another.

Some refactoring is thus needed.

Refactoring idea

I would like for test function parameters to be encapsulated inside its own class, say, Parameters. The class has the following main properties:

With the following methods:

Each parameter must have the following properties:

So the parameters for the circular pipe crack problem can be constructed as follows:

params = Parameters()
params.add_parameter(
  keyword="pipe_radius",
  symbol="t",
  value=3.377e-1,
  type=float,
  description="Radius of the pipe [m]",
)
params.add_parameter(
  keyword="pipe_thickness",
  symbol="R",
  value=3.377e-2,
  type=float,
  description="Thickness of the pipe [m]",
)
params.add_parameter(
  keyword="bending_moment",
  symbol="M",
  value=3.0,
  type=float,
  description="Applied bending moment [MNm]",
)

We can update the value as long as the assigned value is of consistent type. For instance:

params["bending_moment"] = 2.0  # Okay
params["bending_moment"] = "a"  # Raise an exception!

The top-level signature of eval_ method is now will be:

def eval_(xx: np.ndarray, **kwargs):

instead of

def eval_(*args):

This method is called by the evaluate() method:

def evaluate(self, *args):
  ...
  else:
      return self.__class__.eval_(xx, **self.parameters.as_dict())

Note that parameters will be assigned in the call to the actual function as keyword arguments. Now instead of:

def evaluate(
    xx: np.ndarray, parameters: Tuple[float, float, float]
) -> np.ndarray:

we write the signature as:

def evaluate(
      xx: np.ndarray, pipe_radius: float, pipe_thickness: float, bending_moment: float,
) -> np.ndarray:

which is now more self-explanatory and much less cryptic.

The method as_dict() will return a dictionary whose values are each of the entries value; the key of this dictionary must match with the keyword arguments defined in the underlying function. Furthermore, the number of entries in each Parameters must match with the declared keyword arguments, no more and no less. That's why the keyword specified in the Parameters instance must be correct otherwise the function cannot find them.

The output of print() is as follows:

Name            : Parameters-CircularPipeCrack-Verma2015
# of Parameters : 3
Description     : Set of parameters for the circular pipe crack problem
                  used in Verma et al. (2015)

  No.  Symbol     Keyword          Value         Type             Description                  

-----  ------  --------------  --------------   ------   --------------------------------
    1    r       pipe_radius      3.377e-1       float       Radius of the pipe [m]
    2    t     pipe_thickness     3.377e-2       float      Thickness of the pipe [m]
    3    M     bending_moment        3.0         float     Applied bending moment [MNm]