{ "cells": [ { "cell_type": "markdown", "id": "46ec8557", "metadata": {}, "source": [ "# *Flume* Overview\n", "The purpose of this file is to outline in detail the use of *Flume* and a workflow for the framework. For a new user, this file will broadly outline the following:\n", "- Nomenclature utilized for the framework\n", "- Construction of an *Analysis* class, which internally uses *State* objects to define variables and outputs\n", "- Defining instances of *Analysis* classes and the role of `sub_analyses` to connect outputs to inputs\n", "- Assembly of a *System* with specific instances of *Analysis* classes\n", "To address these points, this notebook will provide sections of code with accompanying descriptions regarding the use of the various base classes and how to set up an optimization problem.\n", "\n", "## Framework Nomenclature\n", "Before including any code, it is important to clarify the nomenclature that is used within the framework. All *Analysis* classes contain at least one variable and output, and they may optionally include parameters. In the context of *Flume*, these are defined as follows.\n", "\n", "- **Parameter**: Numerical, boolean, string, or other data type that are nominally set during the construction of an *Analysis* object. Parameters represent one type of inputs to an analysis procedure, but they are not candidates for design variables, as they are set during object construction and are (generally) not changed after this point. Parameters are stored within a dictionary, an attribute of the *Analysis* class entitled `parameters`, where the keys correspond to the parameters' names and the values take on the actual values of the parameters.\n", "- **Variable**: Numerical quantities (either floats or NumPy arrays) that are candidates for design variables during optimization. This is the second type of inputs to an analysis procedure and are numerical quantities used to compute outputs of an *Analysis* class. Variables are stored within a dictionary, an attribute of the *Analysis* class entitled `variables`, where the keys correspond to the local variable name and the values are instances of *State* objects. By defining each variable as a *State*, it contains information beyond its numerical value, including the data type, shape, description, derivative, and source. If sub-analyses exist for an *Analysis* class, then variables may be sourced from another object if its local variable name is the same as an output of a connected sub-analysis (see **insert section** for additional details).\n", "- **Output**: Outputs are similar in nature to variables, but they are the quantities determined from performing the analysis procedure with a given set of parameters and variables. Output values are also wrapped within *State* objects, so information about an output is accessed in the same way as variables by accessing the `outputs` attribute of an *Analysis* class. As mentioned above, outputs of a sub-analysis can be connected to variables for another *Analysis* class if they share the same local names.\n", "\n", "These three terms are used throughout the framework but will be further explained in context below. " ] }, { "cell_type": "markdown", "id": "28c631ae", "metadata": {}, "source": [ "## Defining an *Analysis* Class\n", "\n", "Now, consider the code below, which defines the Rosenbrock *Analysis*, which is responsible for evaluating\n", "\n", "$$\n", "f(x,y)=(a-x)^2 + b(y - x^2)^2,\n", "$$\n", "\n", "where $a$ and $b$ are parameter values and $x$ and $y$ are current variable values. The entire code is shown first, and then each method is explained in greater detail. \n", "\n", "\n", "```python\n", "from flume.base_classes.analysis import Analysis\n", "from flume.base_classes.state import State\n", "\n", "class Rosenbrock(Analysis):\n", "\n", " def __init__(self, obj_name: str, sub_analyses=[], **kwargs):\n", "\n", " # Set the default parameters\n", " self.default_parameters = {\"a\": 1.0, \"b\": 100.0}\n", "\n", " # Perform the base class object initialization\n", " super().__init__(obj_name=obj_name, sub_analyses=sub_analyses, **kwargs)\n", "\n", " # Set the default State for the variables\n", " xvar = State(value=0.0, desc=\"x state value\", source=self)\n", " yvar = State(value=0.0, desc=\"y state value\", source=self)\n", "\n", " # Construct variables dictionary\n", " self.variables = {\"x\": xvar, \"y\": yvar}\n", "\n", " return\n", "\n", " def _analyze(self):\n", " # Extract the variable values\n", " x = self.variables[\"x\"].value\n", " y = self.variables[\"y\"].value\n", "\n", " # Extract the parameter values\n", " a = self.parameters[\"a\"]\n", " b = self.parameters[\"b\"]\n", "\n", " # Compute the value of the Rosenbrock function\n", " f = (a - x) ** 2 + b * (y - x**2) ** 2\n", "\n", " # Update the analyzed attribute\n", " self.analyzed = True\n", "\n", " # Store the outputs\n", " self.outputs = {}\n", "\n", " self.outputs[\"f\"] = State(\n", " value=f, desc=\"Rosenbrock function value\", source=self\n", " )\n", "\n", " return\n", "\n", " def _analyze_adjoint(self):\n", " # Extract the derivatives of the outputs\n", " fb = self.outputs[\"f\"].deriv\n", "\n", " # Extract the variable values\n", " x = self.variables[\"x\"].value\n", " y = self.variables[\"y\"].value\n", "\n", " # Extract the variable derivatives\n", " xb = self.variables[\"x\"].deriv\n", " yb = self.variables[\"y\"].deriv\n", "\n", " # Extract the parameter values\n", " a = self.parameters[\"a\"]\n", " b = self.parameters[\"b\"]\n", "\n", " # Compute xb\n", " xb += (2 * (a - x) * -1 + 2.0 * b * (y - x**2) * -2 * x) * fb\n", "\n", " # Compute yb\n", " yb += (2 * b * (y - x**2)) * fb\n", "\n", " # Update the analyzed adjoint attribute\n", " self.adjoint_analyzed = True\n", "\n", " # Assign the derivative values\n", " self.variables[\"x\"].set_deriv_value(deriv_val=xb)\n", " self.variables[\"y\"].set_deriv_value(deriv_val=yb)\n", "\n", " return\n", "```\n", "\n" ] }, { "cell_type": "markdown", "id": "a045a331", "metadata": {}, "source": [ "### `__init__` Method\n", "\n", "The `__init__` method is responsible for constructing the object, similar to a regular Python class. Regardless of the purpose of the *Analysis* class, this method must provide an object name `obj_name`, a list of sub-analyses `sub_analyses`, and any keyword arguments `**kwargs`. When constructing a specific instance of this class, the `sub_analyses` list may be empty or contain other *Analysis* objects, but the definition of the `__init__` method does not have to contain this information. \n", "\n", "Next, the default parameter values are defined for the current class with the following line\n", "```python\n", "self.default_parameters = {\"a\": 1.0, \"b\": 100.0}\n", "```\n", "Default parameters must be provided here, as they are one of the required inputs to compute the outputs. If the user wants to override the default parameter values, they are passed in as keyword arguments during object construction. In the event that an *Analysis* class does not have any parameters, the `default_parameters` attribute should be defined as an empty dictionary to denote this. The base class object initialization is then performed by calling\n", "```python\n", "super().__init__(obj_name=obj_name, sub_analyses=sub_analyses, **kwargs)\n", "```\n", "This performs various backend assignment procedures and is necessary to properly interface with the framework. Similar to the parameters, default variable *States* are defined with\n", "```python\n", "xvar = State(value=0.0, desc=\"x state value\", source=self)\n", "yvar = State(value=0.0, desc=\"y state value\", source=self)\n", "```\n", "Here, the variables for $x$ and $y$ define the default value, description, and source information. When defining the source information, it is always `self`. Even if the variable is intended to be sourced from another object, this information is updated on the backend if a connection is identified between an outputs and variables. Finally, the variable *States* are assigned into the `variables` dictionary\n", "```python\n", "self.variables = {\"x\": xvar, \"y\": yvar}\n", "```\n", "This concludes the required parts of the constructor method, but additional modifications are possible based on a user's individual needs for their *Analysis* classes." ] }, { "cell_type": "markdown", "id": "0ca9b63f", "metadata": {}, "source": [ "### `_analyze` Method\n", "\n", "The primary intent of this method is to compute the outputs of interest using the input values for the parameters and variables. To do so, the variable values and parameters are extracted with\n", "```python\n", "# Extract the variable values\n", "x = self.variables[\"x\"].value\n", "y = self.variables[\"y\"].value\n", "\n", "# Extract the parameter values\n", "a = self.parameters[\"a\"]\n", "b = self.parameters[\"b\"]\n", "```\n", "Here, it should be noted that variables must include the additional `.value` to access the value contained within the associated *State* object. Parameters do not require this because they are not wrapped with the *State* class. The next section of this method is dedicated to evaluating discipline specific computations that determine the outputs. For the Rosenbrock function, this is done by simply evaluating\n", "```python\n", "f = (a - x) ** 2 + b * (y - x**2) ** 2\n", "```\n", "After the outputs are computed, the user should update the `analyzed` attribute to denote that computations have concluded for the current *Analysis*. \n", "```python\n", "self.analyzed = True\n", "```\n", "This is used internally to avoid recomputing data if an *Analysis* appears multiple times within a *System* before variable values are updated. Then, the outputs are stored within the `outputs` dictionary, where the values are again wrapped in *State* objects.\n", "```python\n", "# Store the outputs\n", "self.outputs = {}\n", "\n", "self.outputs[\"f\"] = State(\n", " value=f, desc=\"Rosenbrock function value\", source=self\n", ")\n", "```\n", "Nothing is returned from this method, as the output values are stored within the class instance and are accessed by interacting with this object.\n" ] }, { "cell_type": "markdown", "id": "8efd5b06", "metadata": {}, "source": [ "### `_analyze_adjoint` Method\n", "\n", "Comparable to the previous method, `_analyze_adjoint` is responsible for propagating the derivatives from the outputs back to the variables. Within *Flume*, the variables for an individual discipline are treated as design variables for the adjoint method. As a result, if the outputs are explicit functions of the input variables, then the user is responsible for computing the partial derivatives of the outputs with respect to the variables and accounting for the impact of the adjoint variables. However, if an *Analysis* internally performs some linear or nonlinear solve to compute additional state variables, the full form of the adjoint method should be used to compute the contributions to the variables' derivatives. A full discussion of the adjoint method is not in the scope of this overview, but its role within *Flume* can be understood through this example. To begin the method, variable and output derivatives are extracted along with the variable values. Similar to values, derivatives are accessed using the `.deriv` extension for *State* objects.\n", "```python\n", "# Extract the derivatives of the outputs\n", "fb = self.outputs[\"f\"].deriv\n", "\n", "# Extract the variable values\n", "x = self.variables[\"x\"].value\n", "y = self.variables[\"y\"].value\n", "\n", "# Extract the variable derivatives\n", "xb = self.variables[\"x\"].deriv\n", "yb = self.variables[\"y\"].deriv\n", "\n", "# Extract the parameter values\n", "a = self.parameters[\"a\"]\n", "b = self.parameters[\"b\"]\n", "\n", "```\n", "Next, since this discipline computes $f$ as an explicit function of $x$ and $y$, the adjoint method simplifies to multiplying the adjoint variables $\\bar{f}$ or `fb` by the partial derivatives of the output with respect to each variable. Note that the addition assignment `+=` operator is used to update, not overwrite, the derivatives for the variables. This is critical to ensure that derivatives already computed for $\\bar{x}$ (`xb`) and $\\bar{y}$ (`yb`) do not get discarded.\n", "```python\n", "# Compute contributions to xb\n", "xb += (2 * (a - x) * -1 + 2.0 * b * (y - x**2) * -2 * x) * fb\n", "\n", "# Compute contributions to yb\n", "yb += (2 * b * (y - x**2)) * fb\n", "```\n", "After computing the derivatives, the user should update the `adjoint_analyzed` attribute to denote that computations for the derivatives have concluded, similar to the behavior for the `_analyze` method.\n", "```python\n", "# Update the analyzed adjoint attribute\n", "self.adjoint_analyzed = True\n", "```\n", "Finally, the derivative values are assigned to the *State* objects stored within the `variables` dictionary, which ensures that these quantities are stored before concluding the current `_analyze_adjoint` method.\n", "```python\n", "# Assign the derivative values\n", "self.variables[\"x\"].set_deriv_value(deriv_val=xb)\n", "self.variables[\"y\"].set_deriv_value(deriv_val=yb)\n", "```\n", "Again, nothing is returned from this method, as the derivatives are updated within the *State* objects for the specific class instance." ] }, { "cell_type": "markdown", "id": "74a5ec83", "metadata": {}, "source": [ "## Setting up a *System*\n", "\n", "Using the procedure outlined above to define various *Analysis* classes, emphasis will now be placed on combining *Analysis* instances together to construct a *System*. Here, the classes associated with the constrained Rosenbrock example will be utilized, which are located [here](https://github.com/smdogroup/flume/blob/main/examples/rosenbrock/rosenbrock_problem_classes.py). First, the entire script to define a *System* is shown, and detailed descriptions will be included below.\n", "```python\n", "# Construct the design variables object\n", "rosenbrock_dvs = RosenbrockDVs(obj_name=\"dvs\", sub_analyses=[])\n", "\n", "# Construct the analysis object for the Rosenbrock function\n", "a = 1.0\n", "b = 100.0\n", "\n", "rosenbrock = Rosenbrock(\n", " obj_name=\"rosenbrock\", sub_analyses=[rosenbrock_dvs], a=a, b=b\n", ")\n", "\n", "# Construct the analysis object for the constraint on the design variables\n", "rosenbrock_con = RosenbrockConstraint(\n", " obj_name=\"con\", sub_analyses=[rosenbrock_dvs]\n", ")\n", "\n", "# Construct the system\n", "flume_sys = System(\n", " sys_name=\"rosen_sys_con\",\n", " top_level_analysis_list=[rosenbrock, rosenbrock_con],\n", " log_name=\"flume.log\",\n", " log_prefix=\"examples/rosenbrock_constrained\",\n", ")\n", "\n", "# Graph the system network to visualize connections\n", "graph = flume_sys.graph_network(\n", " filename=\"ConstrainedRosenbrock\", output_directory=\"examples/rosenbrock_constrained\"\n", ")\n", "```" ] }, { "cell_type": "markdown", "id": "564b3cf6", "metadata": {}, "source": [ "### Creating *Analsis* Instances\n", "\n", "Before assembling the *System*, it is necessary to create instances of the *Analysis* classes that are intended to be used within the *System*. The first object is the `RosenBrockDVs` class:\n", "```python\n", "# Construct the design variables object\n", "rosenbrock_dvs = RosenbrockDVs(obj_name=\"dvs\", sub_analyses=[])\n", "```\n", "This simply maps design variables `x_dv` and `y_dv` to `x` and `y`. This creates a single set of outputs for `x` and `y` that can then be distributed to the other parts of the *System*. A unique name is given to the object, and no sub-analyses are provided, as this object defines the source for `x_dv` and `y_dv`. " ] }, { "cell_type": "markdown", "id": "ca65ca15", "metadata": {}, "source": [ "\n", "\n", "\n", "
\n", "Click here to view the RosenbrockDVs class\n", "\n", "```python\n", "from flume.base_classes.analysis import Analysis\n", "from flume.base_classes.state import State\n", "\n", "class RosenbrockDVs(Analysis):\n", " def __init__(self, obj_name: str, sub_analyses=[], **kwargs):\n", " \"\"\"\n", " Analysis class that defines the design variables for the Rosenbrock function. This is needed to \n", " define single values for x and y that can be treated as design variables, which are then\n", " distributed throughout the System (i.e. to the objective and constriant functions).\n", "\n", " Parameters\n", " ----------\n", " obj_name : str\n", " Name for the analysis object\n", " sub_analyses : list\n", " A list of sub-analyses for the object\n", "\n", " Keyword Arguments\n", " -----------------\n", " No input parameters for this object, so no **kwargs.\n", " \"\"\"\n", "\n", " # Set the default parameters\n", " self.default_parameters = {}\n", "\n", " # Perform the base class object initialization\n", " super().__init__(obj_name=obj_name, sub_analyses=sub_analyses, **kwargs)\n", "\n", " # Set the default states for the variables\n", " x_dv_var = State(\n", " value=0.0, desc=\"Value to use for the x design variable\", source=self\n", " )\n", "\n", " y_dv_var = State(\n", " value=0.0, desc=\"Value to use for the y design variable\", source=self\n", " )\n", "\n", " self.variables = {\"x_dv\": x_dv_var, \"y_dv\": y_dv_var}\n", "\n", " return\n", "\n", " def _analyze(self):\n", " \"\"\"\n", " Maps x_dv, y_dv -> x, y, where x, y are then distributed throughout the System.\n", " \"\"\"\n", "\n", " # Extract the variables\n", " x_dv = self.variables[\"x_dv\"].value\n", " y_dv = self.variables[\"y_dv\"].value\n", "\n", " # Update the analyzed attribute\n", " self.analyzed = True\n", "\n", " # Store the outputs in the outputs dictionary\n", " self.outputs = {}\n", "\n", " self.outputs[\"x\"] = State(value=x_dv, desc=\"Value for x\", source=self)\n", "\n", " self.outputs[\"y\"] = State(value=y_dv, desc=\"Value for y\", source=self)\n", "\n", " return\n", "\n", " def _analyze_adjoint(self):\n", " \"\"\"\n", " Propagates the derivatives for x and y back to the design variables x_dv and y_dv\n", " \"\"\"\n", "\n", " # Extract the output derivatives\n", " xb = self.outputs[\"x\"].deriv\n", " yb = self.outputs[\"y\"].deriv\n", "\n", " # Extract the variable derivatives\n", " x_dvb = self.variables[\"x_dv\"].deriv\n", " y_dvb = self.variables[\"y_dv\"].deriv\n", "\n", " # Update the derivative values\n", " x_dvb += xb\n", " y_dvb += yb\n", "\n", " # Update the analyzed adjoint attribute\n", " self.adjoint_analyzed = True\n", "\n", " # Set the derivative values\n", " self.variables[\"x_dv\"].set_deriv_value(deriv_val=x_dvb)\n", "\n", " self.variables[\"y_dv\"].set_deriv_value(deriv_val=y_dvb)\n", "\n", " return\n", "```\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "a31f32de", "metadata": {}, "source": [ "Next, instances of the `Rosenbrock` and `RosenbrockConstraint` *Analysis* classes are created\n", "```python\n", "# Construct the analysis object for the Rosenbrock function\n", "a = 1.0\n", "b = 100.0\n", "\n", "rosenbrock = Rosenbrock(\n", " obj_name=\"rosenbrock\", sub_analyses=[rosenbrock_dvs], a=a, b=b\n", ")\n", "\n", "# Construct the analysis object for the constraint on the design variables\n", "rosenbrock_con = RosenbrockConstraint(\n", " obj_name=\"con\", sub_analyses=[rosenbrock_dvs]\n", ")\n", "```\n", "Here, the values for `a` and `b` are passed as keyword arguments to the `Rosenbrock class` to set the parameter values. Also, each class takes the `rosenbrock_dvs` instance as a sub-analysis. This creates a connection between the outputs for `x` and `y` from `rosenbrock_dvs` to the input `variables` for both `rosenbrock` and `rosenbrock_con`. If this is not done, then the values for `x` and `y` will not be distributed throughout the model correctly. " ] }, { "cell_type": "markdown", "id": "e8e42a41", "metadata": {}, "source": [ "\n", "\n", "\n", "
\n", "Click here to view the RosenbrockConstraint class\n", "\n", "```python\n", "from flume.base_classes.analysis import Analysis\n", "from flume.base_classes.state import State\n", "\n", "class RosenbrockConstraint(Analysis):\n", " def __init__(self, obj_name: str, sub_analyses=[RosenbrockDVs], **kwargs):\n", " \"\"\"\n", " Analysis class that computes the value of the constraint function, which is used to constrain \n", " the design space to a circular region.\n", "\n", " Parameters\n", " ----------\n", " obj_name : str\n", " Name for the analysis object\n", " sub_analyses : list\n", " A list of sub-analyses for the object, which nominally contains an instance of \n", " the RosenbrockDVs class\n", "\n", " Keyword Arguments\n", " -----------------\n", " No input parameters for this object, so no **kwargs.\n", " \"\"\"\n", "\n", " # Set the default parameters\n", " self.default_parameters = {}\n", "\n", " # Perform the base class object initialization\n", " super().__init__(obj_name=obj_name, sub_analyses=sub_analyses, **kwargs)\n", "\n", " # Set the default State for the variables\n", " xvar = State(value=0.0, desc=\"x state value\", source=self)\n", " yvar = State(value=0.0, desc=\"y state value\", source=self)\n", "\n", " # Construct variables dictionary\n", " self.variables = {\"x\": xvar, \"y\": yvar}\n", "\n", " return\n", "\n", " def _analyze(self):\n", " \"\"\"\n", " Computes the distance from the origin for the current values of x and y.\n", " \"\"\"\n", "\n", " # Extract the variable values\n", " x = self.variables[\"x\"].value\n", " y = self.variables[\"y\"].value\n", "\n", " # Compute the value of the constraint\n", " g = x**2 + y**2\n", "\n", " # Update the analyzed attribute\n", " self.analyzed = True\n", "\n", " # Store the outputs in the outputs dictionary\n", " self.outputs = {}\n", "\n", " self.outputs[\"g\"] = State(\n", " value=g,\n", " desc=\"Distance from the origin, which is used to constrain the design space to a circle\",\n", " source=self,\n", " )\n", "\n", " return\n", "\n", " def _analyze_adjoint(self):\n", " \"\"\"\n", " Performs the adjoint analysis for the constraint function to propagate the derivatives back to x and y.\n", " \"\"\"\n", "\n", " # Extract the variable values\n", " x = self.variables[\"x\"].value\n", " y = self.variables[\"y\"].value\n", "\n", " # Extract the variable derivatives\n", " xb = self.variables[\"x\"].deriv\n", " yb = self.variables[\"y\"].deriv\n", "\n", " # Extract the output derivatives\n", " gb = self.outputs[\"g\"].deriv\n", "\n", " # Add the contributions to xb and yb\n", " xb += gb * 2 * x\n", " yb += gb * 2 * y\n", "\n", " # Update the analyzed adjoint attribute\n", " self.adjoint_analyzed = True\n", "\n", " # Assign the derivative values\n", " self.variables[\"x\"].set_deriv_value(deriv_val=xb)\n", " self.variables[\"y\"].set_deriv_value(deriv_val=yb)\n", "\n", " return\n", "\n", "```\n", "\n", "
" ] }, { "cell_type": "markdown", "id": "da4f01f0", "metadata": {}, "source": [ "\n", "Finally, the *System* is constructed with the following\n", "```python\n", "# Construct the system\n", "flume_sys = System(\n", " sys_name=\"rosen_sys_con\",\n", " top_level_analysis_list=[rosenbrock, rosenbrock_con],\n", " log_name=\"flume.log\",\n", " log_prefix=\"examples/rosenbrock_constrained\",\n", ")\n", "```\n", "A name is provided for the system, and the top-level analysis list is specified by providing the `rosenbrock` and `rosenbrock_con` objects as a list. This top-level analysis list corresponds to the *Analysis* objects whose outputs will be used as the objective or constraint functions for optimization. Additionally, a name for the log file and output directory are provided for file management purposes. To ensure that outputs and variables are connected correctly, the structure of the *System* can be visualized by calling the following function.\n", "```python\n", "# Graph the system network to visualize connections\n", "graph = flume_sys.graph_network(\n", " filename=\"ConstrainedRosenbrock\", output_directory=\"examples/rosenbrock_constrained\"\n", ")\n", "```\n", "This constructs a document using graphviz that provides a visual representation of the *System* network. If it is found that outputs and variables are not connected within the diagram, this likely indicates that a `sub_analyses` list is missing an entry to define the flow of information." ] }, { "cell_type": "markdown", "id": "d0ec2d52", "metadata": {}, "source": [ "## Formulating an Optimization Problem\n", "\n", "With the *System* assembled, the next step is to declare the design variables, objective function, and constraints that will be utilized for the optimization problem. In all cases, global names are utilized to identify these *States*, which is denoted with `object_name.local_state_name`. For the design variables\n", "```python\n", "# Declare the design variables for the system\n", "flume_sys.declare_design_vars(\n", " global_var_name={\n", " \"dvs.x_dv\": {\"lb\": -1.5, \"ub\": 1.5},\n", " \"dvs.y_dv\": {\"lb\": -1.5, \"ub\": 1.5},\n", " }\n", ")\n", "```\n", "The user can optionally provide applicable scalar lower and upper bound values. If the variable is an array, then the bounds are still provided as scalars, and each variable in the array is then given the same bounds. Next, the objective function is specified\n", "```python\n", "# Declare the objective\n", "flume_sys.declare_objective(global_obj_name=\"rosenbrock.f\", obj_scale=1.0)\n", "```\n", "The second argument `obj_scale` is optional, but it defines a scaling factor that, when multiplied by the objective function's value, should scale the value to $\\mathcal{O}(1)$. This product is the quantity that the optimizer will see. Finally, constraints are specified with\n", "```python\n", "# Declare the constraint\n", "flume_sys.declare_constraints(\n", " global_con_name={\"con.g\": {\"direction\": \"leq\", \"rhs\": 1.0}}\n", ")\n", "```\n", "The syntax here corresponds to providing a dictionary of dictionaries, where each outer key corresponds to the constraint affecting the optimization problem. The inner dictionaries provide information regarding the constraint type and direction, that is less than or equal to `leq`, greater than or equal to `geq`, or an equality constraint `both`. Additionally, the right-hand side value `rhs` for the constraint is provided, which is also used to scale the constraints internally. For this example, the declared constraint is such that the output `g` of the object named `con` should be less than or equal to $1.0$." ] }, { "cell_type": "markdown", "id": "19a438d1", "metadata": {}, "source": [ "\n", "## Optimizing with the SciPy Interface\n", "\n", "After constructing the *System* and specifying the optimization problem formulation, numerical optimization can be performed by using one of *Flume*'s optimizer interfaces. To date, the supported interfaces include [ParOpt](https://github.com/smdogroup/paropt) and [SciPy](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html). In this example, the SciPy interface will be utilized, which is done with the following lines\n", "```python\n", "# Construct the Scipy interface\n", "interface = FlumeScipyInterface(flume_sys=flume_sys)\n", "\n", "# Set a random starting point\n", "init_global_vars = {\n", " \"dvs.x_dv\": np.random.uniform(low=-1.0, high=1.0), \n", " \"dvs.y_dv\": np.random.uniform(low=1.0, high=1.0)\n", "}\n", "initial_point = interface.set_initial_point(\n", " initial_global_vars=init_global_vars\n", ")\n", "\n", "# Optimize the problem with SciPy minimize\n", "x, res = interface.optimize_system(x0=initial_point, method=\"SLSQP\")\n", "```\n", "First, an instance of the *FlumeScipyInterface* is constructed by providing the class with the instance of the *System*. Then, an initial starting point, random in this case, is determined and mapped into a NumPy array with the `set_initial_point` method. Finally, the *System* is optimized by calling `optimize_system` with the initial point and desired method. The default method is SLSQP, and the user can optionally provide a maximum number of iterations and options for the optimization method that align with the SciPy documentation. The output of this method is the optimal set of design variables `x` and the *OptimizationResult* instance from SciPy `res`. " ] }, { "cell_type": "markdown", "id": "3c3fb185", "metadata": {}, "source": [ "### Executing the Optimization Script\n", "\n", "Below the setup of the *System* and optimization portions of the script are repeated and can be executed to explore *Flume* in action. This will print the optimal values for the design variables, which should be $x^*\\approx 0.786, y^* \\approx 0.618$ for the constrained Rosenbrock example. It will also output the *OptimizeResult* instance returned by SciPy." ] }, { "cell_type": "code", "execution_count": null, "id": "b31a7a09", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "WARNING: There was an error checking the latest version of pip.\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Optimal design variables: [0.78641515 0.61769831]\n", " message: Optimization terminated successfully\n", " success: True\n", " status: 0\n", " fun: 0.0456748087195002\n", " x: [ 7.864e-01 6.177e-01]\n", " nit: 26\n", " jac: [-1.911e-01 -1.501e-01]\n", " nfev: 35\n", " njev: 26\n" ] }, { "name": "stderr", "output_type": "stream", "text": [ "Method SLSQP does not use Hessian information (hess).\n", "Unknown solver options: verbose\n" ] } ], "source": [ "import sys\n", "import os\n", "!pip install -r requirements.txt --quiet\n", "import numpy as np\n", "\n", "# Add the root directory of the repository \n", "sys.path.append(os.path.abspath(\"../..\"))\n", "\n", "from flume.base_classes.system import System\n", "from flume.interfaces.scipy_interface import FlumeScipyInterface\n", "from examples.rosenbrock.rosenbrock_problem_classes import RosenbrockDVs, Rosenbrock, RosenbrockConstraint\n", "\n", "# Construct the design variables object\n", "rosenbrock_dvs = RosenbrockDVs(obj_name=\"dvs\", sub_analyses=[])\n", "\n", "# Construct the analysis object for the Rosenbrock function\n", "a = 1.0\n", "b = 100.0\n", "\n", "rosenbrock = Rosenbrock(\n", " obj_name=\"rosenbrock\", sub_analyses=[rosenbrock_dvs], a=a, b=b\n", ")\n", "\n", "# Construct the analysis object for the constraint on the design variables\n", "rosenbrock_con = RosenbrockConstraint(\n", " obj_name=\"con\", sub_analyses=[rosenbrock_dvs]\n", ")\n", "\n", "# Construct the system\n", "flume_sys = System(\n", " sys_name=\"rosen_sys_con\",\n", " top_level_analysis_list=[rosenbrock, rosenbrock_con],\n", " log_name=\"flume.log\",\n", " log_prefix=\".\",\n", ")\n", "\n", "# Declare the design variables for the system\n", "flume_sys.declare_design_vars(\n", " global_var_name={\n", " \"dvs.x_dv\": {\"lb\": -1.5, \"ub\": 1.5},\n", " \"dvs.y_dv\": {\"lb\": -1.5, \"ub\": 1.5},\n", " }\n", ")\n", "\n", "# Declare the objective\n", "flume_sys.declare_objective(global_obj_name=\"rosenbrock.f\")\n", "\n", "# Declare the constraint\n", "flume_sys.declare_constraints(\n", " global_con_name={\"con.g\": {\"direction\": \"leq\", \"rhs\": 1.0}}\n", ")\n", "\n", "\n", "# Construct the Scipy interface\n", "interface = FlumeScipyInterface(flume_sys=flume_sys)\n", "\n", "# Set a random starting point\n", "init_global_vars = {\n", " \"dvs.x_dv\": np.random.uniform(low=-1.0, high=1.0), \n", " \"dvs.y_dv\": np.random.uniform(low=1.0, high=1.0)\n", "}\n", "initial_point = interface.set_initial_point(\n", " initial_global_vars=init_global_vars\n", ")\n", "\n", "# Optimize the problem with SciPy minimize\n", "x, res = interface.optimize_system(x0=initial_point, method=\"SLSQP\")\n", "\n", "print(\"Optimal design variables: \", x)\n", "print(res)\n" ] }, { "cell_type": "markdown", "id": "aaa573f6", "metadata": {}, "source": [ "## Other Resources to Review\n", "\n", "This concludes the overview of *Flume* and the descriptions provided in context of the constrained Rosenbrock example. For new users, exploring additional examples within the [examples gallery](https://github.com/smdogroup/flume/tree/main/examples) will provide further insight into *Flume* in the context of different problems. A few other useful methods for *Analysis* classes are also discussed in the [methods overview](./analysis_methods_demo.ipynb), although these are intended to be helper methods and do not constitute critical features for using the framework. " ] } ], "metadata": { "kernelspec": { "display_name": "venv", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.10.8" } }, "nbformat": 4, "nbformat_minor": 5 }