diff --git a/idaes_examples/notebooks/_toc.yml b/idaes_examples/notebooks/_toc.yml index 43a1de18..fbed8572 100644 --- a/idaes_examples/notebooks/_toc.yml +++ b/idaes_examples/notebooks/_toc.yml @@ -20,6 +20,9 @@ parts: - file: docs/diagnostics/diagnostics_toolbox_doc - file: docs/diagnostics/degeneracy_hunter_doc - file: docs/diagnostics/structural_singularity_doc + - file: docs/scaling/index + sections: + - file: docs/scaling/scaler_workshop_doc - file: docs/param_est/index sections: - file: docs/param_est/parameter_estimation_nrtl_using_state_block_doc diff --git a/idaes_examples/notebooks/docs/scaling/index.md b/idaes_examples/notebooks/docs/scaling/index.md new file mode 100644 index 00000000..aedc52ce --- /dev/null +++ b/idaes_examples/notebooks/docs/scaling/index.md @@ -0,0 +1,4 @@ +# Scaling + +IDAES Scaling Toolbox +* How to write modular Scalers using the toolbox diff --git a/idaes_examples/notebooks/docs/scaling/scaler_workshop.ipynb b/idaes_examples/notebooks/docs/scaling/scaler_workshop.ipynb new file mode 100644 index 00000000..be3c5350 --- /dev/null +++ b/idaes_examples/notebooks/docs/scaling/scaler_workshop.ipynb @@ -0,0 +1,2018 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "1c64380e", + "metadata": { + "tags": [ + "header", + "hide-cell" + ] + }, + "outputs": [], + "source": [ + "###############################################################################\n", + "# The Institute for the Design of Advanced Energy Systems Integrated Platform\n", + "# Framework (IDAES IP) was produced under the DOE Institute for the\n", + "# Design of Advanced Energy Systems (IDAES).\n", + "#\n", + "# Copyright (c) 2018-2023 by the software owners: The Regents of the\n", + "# University of California, through Lawrence Berkeley National Laboratory,\n", + "# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon\n", + "# University, West Virginia University Research Corporation, et al.\n", + "# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md\n", + "# for full copyright and license information.\n", + "###############################################################################" + ] + }, + { + "cell_type": "markdown", + "id": "604312db", + "metadata": {}, + "source": [ + "# How to Create Scaler Objects in IDAES\n", + "\n", + "Author: Andrew Lee\n", + "Maintainer: Doug Allan\n", + "Updated: 2024-10-24\n", + "\n", + "## Introduction\n", + "\n", + "
\n", + "NOTE All the suggestions in this introduction should be viewed as \"rules-of-thumb\" and not taken as absolute guidance. There are many cases where alternative approaches may give as-good or better results and you should always consider the meaning of the scaling factors you are applying and how they affect the solver's behavior. \n", + "
\n", + "\n", + "Solving general non-linear problems has always been challenging, and is highly dependent on how well scaled the model is. In many cases, as much time (or more) is spent trying to improve the model formulation and scaling as was spent writing the original model. To assist molders with this task, IDAES has implemented a Scaling Toolbox which contains a number of useful tools for common scaling techniques as well as a standard interface and form for how to write scaling routines.\n", + "\n", + "The goal of this workshop is to take you through the process of writing a general-purpose, modular scaling routine for an equilibrium reactor example. By the end of this exercise you should:\n", + "\n", + "* understand the ``CustomScalerBase`` class and how to apply the tools it contains,\n", + "* understand how to use ``CustomScalerBase`` to set up a modular scaling routine for a model,\n", + "* understand how to use the Diagnostics Toolbox to check for scaling issues in a model.\n", + "\n", + "## How to Write a Scaling Routine\n", + "\n", + "
The golden rule when developing a scaling routine to a model is to always think about what you are doing and why. Bad scaling is often worse than no scaling at all, so assigning arbitrary scaling factors should be avoided. Always start by taking the time to look over the model you want to scale and understand what variables and constraints are present. For variables, you should ask yourself what the expected range of magnitudes will be; assigning an arbitrary default value should be avoided. For constraints you should ask yourself what the expected magnitude of each additive term will be, how much these vary from each other, and which term is likely to be most significant in terms of variation (partial derivatives).
\n", + "\n", + "
\n", + "NOTE Different solvers behave in different ways, and you may find cases where tuning scaling for one solver results in worse performance for another.\n", + " \n", + "You should always consider the end-goal when writing a Scaler; if you are writing a routine for a specific application and solver then you may wish to tune the scaling factors for best performance, however if you are writing a general-purpose Scaler then you should aim for scaling that will work for a wide range of conditions and solver.\n", + "
\n", + "\n", + "Below are some general suggestions for developing scaling routines.\n", + " \n", + "* Order of magnitude estimates are generally good enough (and often better than exact values).\n", + "* Start with what you know the most about, and work out from there.\n", + "* If in doubt, start by scaling variables first, and then scale constraints based on the variable scaling.\n", + "* Be judicious when applying scaling factors for things you are uncertain about. If in doubt, leave a component unscaled and see what the model diagnostics have to say.\n", + "* Make use of the modular nature of IDAES when writing scaling routines. A unit model developer might not know the expected magnitude of the thermophysical properties they get from a property package, but there should be a scaling routine for the property package that they can call to provide these.\n", + "\n", + "
\n", + "NOTE When dealing with systems of partial differential algebraic equations (PDAEs), such as dynamic systems or those with spatial variation, it is important to consider how scaling may change across the discretized domain. In many of these types of models, you will find significant changes in scale across a small portion of the domain; for example a dynamic model of a step disturbance will show an initial equilibrium state followed by a rapid change in system conditions until a new equilibrium is established. To complicate things further, the location of this ramp can often move significantly with minor changes in system conditions, thus you should not presume that the ramp will remain in the same place.\n", + " \n", + "As a general rule, for scaling PDAE systems with significant changes, you should focus on finding a set of scaling factors that is suitable for the ramp region as this is the part of the model which will be hardest to solve.\n", + "
\n", + "\n", + "### IDAES Scaling Interface and Toolbox\n", + "\n", + "IDAES uses a class-based interface for defining scaling routines, where model developers can create ``Scaler`` objects which define a scaling routine suitable for a type of model or specific application. All models (both those in the IDAES model libraries and user-developed models) should have one or more ``Scaler`` classes defined for them that can be used to apply scaling routines to the model. To assist end-users in identifying a suitable ``Scaler`` for a model, all IDAES models have a ``default_scaler`` attribute which can be set to point to a ``Scaler`` object suitable for that model. Model developers should endeavor to create a reliable, general-purpose ``Scaler`` for each model they create and assign this as the default ``Scaler``. We will demonstrate how to do this at the end of this workshop.\n", + "\n", + "\n", + "## Step 1: Set Up Test Case(s)\n", + "\n", + "Whilst it is possible to develop a scaling routine by looking only at the model code and the resulting variables and constraints, in order to test it we will need one or more test cases to run. These test cases are important for both checking the that ``Scaler`` code runs as expected, and that it also improves the scaling of the model. The more test cases you can check against, the more confident you can be that the ``Scaler`` you have written is suitable for a wide range of applications.\n", + "\n", + "For this example we will develop a general purpose ``Scaler`` for the ``EquilibriumReactor`` model from the core IDAES model library using the saponification property and reaction packages as a test case. The code below imports the necessary packages and creates a function that will build and initialize our test case." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ee243e5e", + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import ConcreteModel, Constraint, units, Var\n", + "\n", + "from idaes.core import FlowsheetBlock\n", + "from idaes.models.unit_models.equilibrium_reactor import (\n", + " EquilibriumReactor,\n", + ")\n", + "from idaes.models.properties.examples.saponification_thermo import (\n", + " SaponificationParameterBlock,\n", + ")\n", + "from idaes.models.properties.examples.saponification_reactions import (\n", + " SaponificationReactionParameterBlock,\n", + ")\n", + "from idaes.core.solvers import get_solver\n", + "from idaes.core.initialization import BlockTriangularizationInitializer\n", + "from idaes.core.util import DiagnosticsToolbox\n", + "\n", + "\n", + "def build_model():\n", + " m = ConcreteModel()\n", + " m.fs = FlowsheetBlock(dynamic=False)\n", + "\n", + " m.fs.properties = SaponificationParameterBlock()\n", + " m.fs.reactions = SaponificationReactionParameterBlock(\n", + " property_package=m.fs.properties\n", + " )\n", + "\n", + " m.fs.equil = EquilibriumReactor(\n", + " property_package=m.fs.properties,\n", + " reaction_package=m.fs.reactions,\n", + " has_equilibrium_reactions=False,\n", + " has_heat_transfer=True,\n", + " has_heat_of_reaction=True,\n", + " has_pressure_change=True,\n", + " )\n", + "\n", + " m.fs.equil.inlet.flow_vol[0].fix(1.0e-03 * units.m**3 / units.s)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"H2O\"].fix(55388.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"NaOH\"].fix(100.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"EthylAcetate\"].fix(\n", + " 100.0 * units.mol / units.m**3\n", + " )\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"SodiumAcetate\"].fix(0.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"Ethanol\"].fix(0.0 * units.mol / units.m**3)\n", + "\n", + " m.fs.equil.inlet.temperature[0].fix(303.15 * units.K)\n", + " m.fs.equil.inlet.pressure[0].fix(101325.0 * units.Pa)\n", + "\n", + " m.fs.equil.heat_duty.fix(0 * units.W)\n", + " m.fs.equil.deltaP.fix(0 * units.Pa)\n", + "\n", + " initializer = BlockTriangularizationInitializer()\n", + " initializer.initialize(m.fs.equil)\n", + "\n", + " return m" + ] + }, + { + "cell_type": "markdown", + "id": "1a67a8f6", + "metadata": {}, + "source": [ + "Before we move on to try to solve the model or develop a ``Scaler``, we should first check to make sure the model is well-posed and that there are not any structural issues that will prevent us from solving the model. The code below creates an instance of the IDAES Diagnostics Toolbox and runs the ``report_structural_issues`` method to ensure there are no warnings." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "c8993db5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Activated Blocks: 5 (Deactivated: 0)\n", + " Free Variables in Activated Constraints: 16 (External: 0)\n", + " Free Variables with only lower bounds: 6\n", + " Free Variables with only upper bounds: 0\n", + " Free Variables with upper and lower bounds: 2\n", + " Fixed Variables in Activated Constraints: 10 (External: 0)\n", + " Activated Equality Constraints: 16 (Deactivated: 0)\n", + " Activated Inequality Constraints: 0 (Deactivated: 0)\n", + " Activated Objectives: 0 (Deactivated: 0)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "0 WARNINGS\n", + "\n", + " No warnings found!\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 Cautions\n", + "\n", + " Caution: 4 variables fixed to 0\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " Try to initialize/solve your model and then call report_numerical_issues()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "m = build_model()\n", + "\n", + "dt = DiagnosticsToolbox(model=m.fs.equil)\n", + "dt.report_structural_issues()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "c7b4d6f9", + "metadata": {}, + "outputs": [], + "source": [ + "# Make sure base model is constructed properly\n", + "dt.assert_no_structural_warnings()" + ] + }, + { + "cell_type": "markdown", + "id": "242909ef", + "metadata": {}, + "source": [ + "In order to fully test our new ``Scaler`` it is also useful to test how the model responds to perturbations in the state. In many ways, this is the real test of a scaling routine as it is easy to write something that gets good scaling for a known state (e.g., auto-scalers), but what we really need is a routine that can get good scaling across a range of conditions.\n", + "\n", + "The cell below creates a function that perturbs the state of our model significantly. Note that the volumetric flowrate has been increased by two orders of magnitude, the inlet concentrations have changed significantly, and we have also made a small change to the temperature." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "e00f12e5", + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.solvers import get_solver\n", + "\n", + "solver = get_solver(\n", + " \"ipopt_v2\", writer_config={\"scale_model\": True, \"linear_presolve\": True}\n", + ")\n", + "\n", + "\n", + "def perturb_model(m):\n", + " m.fs.equil.inlet.flow_vol.fix(1 * units.m**3 / units.s)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"NaOH\"].fix(200.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"EthylAcetate\"].fix(\n", + " 100.0 * units.mol / units.m**3\n", + " )\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"SodiumAcetate\"].fix(50 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"Ethanol\"].fix(1e-8 * units.mol / units.m**3)\n", + "\n", + " m.fs.equil.inlet.temperature.fix(320 * units.K)\n", + " solver.solve(m, tee=True)" + ] + }, + { + "cell_type": "markdown", + "id": "060b2d84", + "metadata": {}, + "source": [ + "Lets apply this perturbation to our example model and see how well it solves." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "7db4db04", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Ipopt 3.13.2: linear_solver=ma57\n", + "max_iter=200\n", + "nlp_scaling_method=gradient-based\n", + "tol=1e-06\n", + "\n", + "\n", + "******************************************************************************\n", + "This program contains Ipopt, a library for large-scale nonlinear optimization.\n", + " Ipopt is released as open source code under the Eclipse Public License (EPL).\n", + " For more information visit http://projects.coin-or.org/Ipopt\n", + "\n", + "This version of Ipopt was compiled from source code available at\n", + " https://github.com/IDAES/Ipopt as part of the Institute for the Design of\n", + " Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE\n", + " Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.\n", + "\n", + "This version of Ipopt was compiled using HSL, a collection of Fortran codes\n", + " for large-scale scientific computation. All technical papers, sales and\n", + " publicity material resulting from use of the HSL codes within IPOPT must\n", + " contain the following acknowledgement:\n", + " HSL, a collection of Fortran codes for large-scale scientific\n", + " computation. See http://www.hsl.rl.ac.uk.\n", + "******************************************************************************\n", + "\n", + "This is Ipopt version 3.13.2, running with linear solver ma57.\n", + "\n", + "Number of nonzeros in equality constraint Jacobian...: 21\n", + "Number of nonzeros in inequality constraint Jacobian.: 0\n", + "Number of nonzeros in Lagrangian Hessian.............: 9\n", + "\n", + "Total number of variables............................: 8\n", + " variables with only lower bounds: 5\n", + " variables with lower and upper bounds: 1\n", + " variables with only upper bounds: 0\n", + "Total number of equality constraints.................: 8\n", + "Total number of inequality constraints...............: 0\n", + " inequality constraints with only lower bounds: 0\n", + " inequality constraints with lower and upper bounds: 0\n", + " inequality constraints with only upper bounds: 0\n", + "\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 0 0.0000000e+00 9.09e+07 1.00e+00 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0\n", + "Reallocating memory for MA57: lfact (247)\n", + " 1r 0.0000000e+00 9.09e+07 9.99e+02 2.5 0.00e+00 - 0.00e+00 7.73e-09R 9\n", + " 2r 0.0000000e+00 8.42e+07 8.24e+03 2.5 7.20e+02 - 1.64e-02 2.59e-02f 1\n", + " 3r 0.0000000e+00 8.37e+07 7.72e+03 1.8 8.82e+04 - 5.56e-04 3.77e-05f 1\n", + " 4r 0.0000000e+00 3.76e+07 2.65e+04 1.8 1.13e+03 0.0 1.27e-01 1.63e-01f 1\n", + " 5r 0.0000000e+00 3.60e+07 2.30e+04 1.8 6.83e+01 1.3 7.53e-02 1.45e-01f 1\n", + " 6r 0.0000000e+00 4.17e+07 1.77e+04 1.8 2.10e+02 0.9 7.11e-02 1.47e-01f 1\n", + " 7r 0.0000000e+00 4.08e+07 1.75e+04 1.8 3.95e+02 0.4 2.35e-01 8.19e-03f 1\n", + " 8r 0.0000000e+00 3.13e+07 1.75e+04 1.8 1.12e+03 -0.1 3.57e-01 3.16e-02f 1\n", + " 9r 0.0000000e+00 7.77e+06 1.74e+04 1.8 1.00e+04 - 1.43e-02 9.06e-03f 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 10r 0.0000000e+00 7.36e+06 1.70e+04 1.8 2.14e+02 - 2.20e-01 2.38e-02f 1\n", + " 11r 0.0000000e+00 5.93e+06 1.67e+04 1.8 1.72e+02 - 7.89e-01 1.95e-01f 1\n", + " 12r 0.0000000e+00 1.54e+06 3.54e+04 1.8 1.06e+02 - 8.80e-01 7.41e-01f 1\n", + " 13r 0.0000000e+00 1.21e+06 2.79e+04 1.8 4.60e+00 - 1.00e+00 2.12e-01h 1\n", + " 14r 0.0000000e+00 3.31e+03 4.79e+01 1.8 2.39e+00 - 1.00e+00 1.00e+00f 1\n", + " 15r 0.0000000e+00 2.85e+03 7.72e+02 -0.2 2.01e+00 - 9.81e-01 9.09e-01f 1\n", + " 16r 0.0000000e+00 2.47e+03 6.60e+02 -0.2 9.87e-01 - 1.00e+00 1.48e-01f 1\n", + " 17r 0.0000000e+00 3.18e-01 8.47e+01 -0.2 1.39e-01 - 1.00e+00 1.00e+00f 1\n", + " 18r 0.0000000e+00 3.18e-01 6.25e+01 -0.2 1.96e-01 - 1.00e+00 1.00e+00f 1\n", + " 19r 0.0000000e+00 3.18e-01 5.46e+00 -0.2 3.26e-02 - 1.00e+00 1.00e+00h 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 20r 0.0000000e+00 3.18e-01 1.44e+02 -1.6 2.34e-01 - 1.00e+00 1.00e+00f 1\n", + " 21r 0.0000000e+00 3.18e-01 1.45e+01 -1.6 9.26e-02 - 9.13e-01 1.00e+00f 1\n", + " 22r 0.0000000e+00 3.18e-01 1.46e+01 -1.6 1.71e-01 - 1.00e+00 1.25e-01f 4\n", + " 23r 0.0000000e+00 3.18e-01 1.44e+01 -1.6 1.24e-01 - 1.00e+00 1.56e-02h 7\n", + " 24r 0.0000000e+00 3.18e-01 1.41e+01 -1.6 1.27e-01 - 1.00e+00 1.56e-02h 7\n", + " 25r 0.0000000e+00 3.18e-01 1.39e+01 -1.6 1.24e-01 - 1.00e+00 1.56e-02h 7\n", + " 26r 0.0000000e+00 3.18e-01 1.37e+01 -1.6 1.22e-01 - 1.00e+00 1.56e-02h 7\n", + " 27r 0.0000000e+00 3.18e-01 1.35e+01 -1.6 1.20e-01 - 1.00e+00 1.56e-02h 7\n", + " 28r 0.0000000e+00 3.18e-01 1.33e+01 -1.6 1.18e-01 - 1.00e+00 1.56e-02h 7\n", + " 29r 0.0000000e+00 3.18e-01 1.31e+01 -1.6 1.17e-01 - 1.00e+00 1.56e-02h 7\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 30r 0.0000000e+00 3.18e-01 1.29e+01 -1.6 1.15e-01 - 1.00e+00 1.56e-02h 7\n", + " 31r 0.0000000e+00 3.18e-01 1.28e+01 -1.6 1.13e-01 - 1.00e+00 1.56e-02h 7\n", + " 32r 0.0000000e+00 3.18e-01 4.28e+01 -1.6 1.11e-01 - 1.00e+00 1.00e+00w 1\n", + " 33r 0.0000000e+00 3.18e-01 1.43e-03 -1.6 2.09e-05 - 1.00e+00 1.00e+00w 1\n", + " 34r 0.0000000e+00 3.18e-01 1.37e+01 -3.7 6.94e-02 - 1.00e+00 1.00e+00f 1\n", + " 35r 0.0000000e+00 3.17e-01 3.73e+04 -3.7 3.39e+00 - 1.44e-01 1.00e+00f 1\n", + " 36r 0.0000000e+00 3.17e-01 6.78e+03 -3.7 5.99e-01 - 1.00e+00 1.00e+00f 1\n", + " 37r 0.0000000e+00 3.17e-01 7.66e+00 -3.7 4.92e-03 - 1.00e+00 1.00e+00h 1\n", + " 38r 0.0000000e+00 3.17e-01 1.65e-04 -3.7 9.43e-05 - 1.00e+00 1.00e+00h 1\n", + " 39r 0.0000000e+00 3.17e-01 1.30e+00 -5.6 9.94e-04 - 1.00e+00 1.00e+00f 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 40r 0.0000000e+00 3.11e-01 6.82e+04 -5.6 3.21e+01 - 1.41e-01 1.00e+00f 1\n", + " 41r 0.0000000e+00 3.11e-01 1.17e+01 -5.6 4.97e-03 - 1.00e+00 1.00e+00h 1\n", + "\n", + "Number of Iterations....: 41\n", + "\n", + " (scaled) (unscaled)\n", + "Objective...............: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Dual infeasibility......: 2.2783833299154238e-01 2.2783833299154238e-01\n", + "Constraint violation....: 3.1132475345243688e-01 3.1132475345243688e-01\n", + "Complementarity.........: 2.7808801399127131e-06 2.7808801399127131e-06\n", + "Overall NLP error.......: 3.1132475345243688e-01 3.1132475345243688e-01\n", + "\n", + "\n", + "Number of objective function evaluations = 109\n", + "Number of objective gradient evaluations = 3\n", + "Number of equality constraint evaluations = 109\n", + "Number of inequality constraint evaluations = 0\n", + "Number of equality constraint Jacobian evaluations = 44\n", + "Number of inequality constraint Jacobian evaluations = 0\n", + "Number of Lagrangian Hessian evaluations = 42\n", + "Total CPU secs in IPOPT (w/o function evaluations) = 0.008\n", + "Total CPU secs in NLP function evaluations = 0.000\n", + "\n", + "EXIT: Converged to a point of local infeasibility. Problem may be infeasible.\n" + ] + } + ], + "source": [ + "perturb_model(m)" + ] + }, + { + "cell_type": "markdown", + "id": "e699eecc", + "metadata": {}, + "source": [ + "As can be seen from the solver logs, IPOPT was unable to find a feasible solution to this problem, and went into restoration from the first iteration. However, there is no reason the perturbed conditions should not be feasible (you can verify this with the `infeasibility_explainer` in the Diagnostics Toolbox if you desire).\n", + "\n", + "There are a few reasons for this, most of which can be resolved by providing better scaling for the model. One of the reasons is because we have a number of concentrations approaching zero which results in a number of very small numbers appearing in the problem.\n", + "\n", + "A bigger issue however is the fact that in our initial model we are feeding reactants in stoichiometric amounts (1:1) meaning that both reactant concentrations go to zero at equilibrium. This results in the Jacobian for the reaction rate constraint becoming singular; with `rate = K_rxn * [NaOH] * [EthylAcetate]` if both concentrations go to zero then the partial derivative of the reaction rate with respect to each concentration is also 0, and thus our solver has no idea of what direction to move when trying to converge the problem. Whilst scaling can help work around this, this is ultimately an indication that our problem is not well formulated. In practice, an Equilibrium reactor model is not well suited for systems involving irreversible rate-based reactions as it requires concentrations to be driven to zero, and is an especially poor choice for stoichiometric feeds." + ] + }, + { + "cell_type": "markdown", + "id": "9c34083f", + "metadata": {}, + "source": [ + "## Step 2: Understanding the Model\n", + "\n", + "Now that we have a test case (or multiple test cases), we can start planning out the new scaling routine. As our goal is to estimate scaling factors for as many of the variables and constraints in the model as possible, the first step is to understand what variables and constraints may be present in the model. Note that we need to be careful to check for all variables and constraints that may exist under different configuration options, and not just those that appear in the our test case(s).\n", + "\n", + "Given the modular nature of IDAES, we need to also make a distinction between those variables and constraints we have direct knowledge of, and those that are created via modular sub-models that we do not know the details of. The most common examples of modular sub-models are the ``StateBlocks`` and ``ReactionBlocks`` created by the associated property packages; we know that these exist and we create these in our models, but we do not know what variables and constraints they may construct. On the other hand, we also have variables and constraints that we construct directly in our model. For the purposes of this we include those variables and constraints constructed by ``ControlVolumes`` as being directly construed; whilst the ``ControlVolume`` might automate the details for us, we directly call methods on the ``ControlVolume`` to create these variables and constraints and we know what they will be based on the instructions we give.\n", + "\n", + "For our example of the ``EquilibriumReactor``, let us take a look at the code in the ``build`` method, which has been copied below for convenience:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "72492bd6", + "metadata": {}, + "outputs": [], + "source": [ + "def build(self):\n", + " \"\"\"\n", + " Begin building model.\n", + "\n", + " Args:\n", + " None\n", + "\n", + " Returns:\n", + " None\n", + " \"\"\"\n", + " # Call UnitModel.build to setup dynamics\n", + " super(EquilibriumReactorData, self).build()\n", + "\n", + " # Build Control Volume\n", + " self.control_volume = ControlVolume0DBlock(\n", + " dynamic=self.config.dynamic, # Config block forces this to be False\n", + " has_holdup=self.config.has_holdup, # Config block forces this to be False\n", + " property_package=self.config.property_package,\n", + " property_package_args=self.config.property_package_args,\n", + " reaction_package=self.config.reaction_package,\n", + " reaction_package_args=self.config.reaction_package_args,\n", + " )\n", + "\n", + " # No need for control volume geometry\n", + "\n", + " self.control_volume.add_state_blocks(\n", + " has_phase_equilibrium=self.config.has_phase_equilibrium\n", + " )\n", + "\n", + " self.control_volume.add_reaction_blocks(\n", + " has_equilibrium=self.config.has_equilibrium_reactions\n", + " )\n", + "\n", + " self.control_volume.add_material_balances(\n", + " balance_type=self.config.material_balance_type,\n", + " has_rate_reactions=self.config.has_rate_reactions,\n", + " has_equilibrium_reactions=self.config.has_equilibrium_reactions,\n", + " has_phase_equilibrium=self.config.has_phase_equilibrium,\n", + " )\n", + "\n", + " self.control_volume.add_energy_balances(\n", + " balance_type=self.config.energy_balance_type,\n", + " has_heat_of_reaction=self.config.has_heat_of_reaction,\n", + " has_heat_transfer=self.config.has_heat_transfer,\n", + " )\n", + "\n", + " self.control_volume.add_momentum_balances(\n", + " balance_type=self.config.momentum_balance_type,\n", + " has_pressure_change=self.config.has_pressure_change,\n", + " )\n", + "\n", + " # Add Ports\n", + " self.add_inlet_port()\n", + " self.add_outlet_port()\n", + "\n", + " if self.config.has_rate_reactions:\n", + " # Add equilibrium reactor performance equation\n", + " @self.Constraint(\n", + " self.flowsheet().time,\n", + " self.config.reaction_package.rate_reaction_idx,\n", + " doc=\"Rate reaction equilibrium constraint\",\n", + " )\n", + " def rate_reaction_constraint(b, t, r):\n", + " # Set kinetic reaction rates to zero\n", + " return b.control_volume.reactions[t].reaction_rate[r] == 0\n", + "\n", + " # Set references to balance terms at unit level\n", + " if (\n", + " self.config.has_heat_transfer is True\n", + " and self.config.energy_balance_type != EnergyBalanceType.none\n", + " ):\n", + " self.heat_duty = Reference(self.control_volume.heat[:])\n", + "\n", + " if (\n", + " self.config.has_pressure_change is True\n", + " and self.config.momentum_balance_type != MomentumBalanceType.none\n", + " ):\n", + " self.deltaP = Reference(self.control_volume.deltaP[:])" + ] + }, + { + "cell_type": "markdown", + "id": "6a704376", + "metadata": {}, + "source": [ + "If we look through the code in the ``build`` method, we can see that the model contains a single 0D Control Volume with ``StateBlocks``, a ``ReactionBlock``, material, energy and momentum balances and one additional constraint (``rate_reaction_constraint``). Thus, we have the following components that need to be scaled:\n", + "\n", + "3 Sub-Models:\n", + "\n", + "1. The inlet state sub-model (``model.control_volume.properties_in``)\n", + "2. The outlet state sub-model (``model.control_volume.properties_out``)\n", + "3. The reaction sub-model (``model.control_volume.reactions``)\n", + "\n", + "Unit Model Variables (from control volume options):\n", + "\n", + "1. Rate-based reaction extent and generation terms\n", + "2. Equilibrium-based reaction extent and generation terms\n", + "3. Inherent reaction extent and generation terms (no explicit argument, but determined by properties)\n", + "4. Phase equilibrium generation terms\n", + "5. Energy balance heat term\n", + "6. Energy balance heats of reaction\n", + "7. Pressure drop\n", + "\n", + "Unit Model Constraints (from control volume + 1 in the ``build`` method):\n", + "\n", + "1. Material balance constraints\n", + "2. Reaction stoichiometry constraints\n", + "3. Energy balance constraints\n", + "4. Pressure balance constraints\n", + "5. ``rate_reaction_constraint``\n", + "\n", + "When writing our ``Scaler`` we will need to consider all of these to determine how best to estimate scaling factors. Before starting however, we should check the numerical diagnostics for each case study, both to see what scaling issues currently exist and to establish a baseline for comparison once we have a proposed ``Scaler`` for our model.\n", + "\n", + "The cell below calls the ``report_numerical_issues`` method for the unscaled test case." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "96ca1083", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 1.540E+12\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 1 Constraint with large residuals (>1.0E-05)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "7 Cautions\n", + "\n", + " Caution: 1 Variable with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme value (<1.0E-04 or >1.0E+04)\n", + " Caution: 1 Constraint with mismatched terms\n", + " Caution: 3 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_constraints_with_large_residuals()\n", + " compute_infeasibility_explanation()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "dt.report_numerical_issues()" + ] + }, + { + "cell_type": "markdown", + "id": "4da977de", + "metadata": {}, + "source": [ + "Looking at the results of the diagnostics, we can see that the test case is not particularly well scaled. The Jacobian condition number is rather large (1e12), and the diagnostics are reporting a number of variables with extremely large or small values, and 3 variables and 2 constraints with poorly scaled Jacobians. As we develop our new ``Scaler`` for the ``EquilibriumReactor`` we will hopefully see these improve.\n", + "\n", + "We can also use the Diagnostics Toolbox to further explore these issues to get a better idea of which variables and constraints might be causing issues. For example, lets display the set of variables and constraints with extreme Jacobian norms." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "85175874", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "The following variable(s) are associated with extreme Jacobian values (<1.0E-04 or>1.0E+04):\n", + "\n", + " fs.equil.control_volume.properties_out[0.0].flow_vol: 9.427E+07\n", + " fs.equil.control_volume.properties_out[0.0].temperature: 4.172E+06\n", + " fs.equil.control_volume.rate_reaction_extent[0.0,R1]: 4.900E+04\n", + "\n", + "====================================================================================\n", + "====================================================================================\n", + "The following constraint(s) are associated with extreme Jacobian values (<1.0E-04 or>1.0E+04):\n", + "\n", + " fs.equil.control_volume.enthalpy_balances[0.0]: 9.436E+07\n", + " fs.equil.control_volume.material_balances[0.0,Liq,H2O]: 5.539E+04\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "dt.display_variables_with_extreme_jacobians()\n", + "dt.display_constraints_with_extreme_jacobians()" + ] + }, + { + "cell_type": "markdown", + "id": "777b15b6", + "metadata": {}, + "source": [ + "These diagnostics can help give us an idea of what may be causing problems in our model. From the output above, we can see that the variables with large Jacobian norms (i.e., high sensitivities) are the outlet flow rate and temperature, as well as the rate-based extent of reaction. We can also see that the constraints with large Jacobian norms are the enthalpy balance and H20 material balance for the reactor. However, caution must be used when interpreting these in isolation, as understanding what these mean is often complicated and initial impressions may be misleading. To get a better picture of what is contributing to extreme Jacobian values you should make use of the tools in the diagnostics ``SVDToolbox``, however that is a topic for another example.\n", + "\n", + "For example, one might wonder why the volumetric flow rate at the outlet of the reactor is so important as it is effectively determined by the inlet flow rate (due to the water balance effectively conserving volume). However, it is important to remember that the Jacobian does not consider the value of the variable, but rather its partial derivatives. Thus, it is important to compare the list of variables and constraints with large Jacobian norms and think about how those intersect.\n", + "\n", + "Let's start by taking a look at the H2O material balance. The cell below prints the constraint expression in a compact form that only shows top level ``Expressions`` rather than expanding these to show the full expression tree." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "4457c423", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fs.equil.control_volume.properties_in[0.0].flow_vol*fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] - fs.equil.control_volume.properties_out[0.0].flow_vol*fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] + fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] == 0" + ] + } + ], + "source": [ + "from idaes.core.util.misc import print_compact_form\n", + "\n", + "print_compact_form(m.fs.equil.control_volume.material_balances[0, \"Liq\", \"H2O\"])" + ] + }, + { + "cell_type": "markdown", + "id": "26e7e329", + "metadata": {}, + "source": [ + "Looking at how the outlet volumetric flowrate appears in the H2O balance equation above, it can be seen that the volumetric flow term is multiplied by the molar concentration of water, $F \\times C_{H2O}$. Whilst $C_{H2O}$ is assumed to be constant in this model (and equal to the molar density of pure water at ambient conditions), this means that the partial derivative of the constraint term with respect to flow is $\\frac{\\partial F C_{H2O}}{\\partial F} = C_{H2O}$; given that $C_{H2O}$ is equal to 5.5E4 mol/liter, you can quickly see why it is being identified as an issue.\n", + "\n", + "If we look at the energy balance, we will find that it is similar." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "708d8d2d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fs.properties.dens_mol*fs.properties.cp_mol*fs.equil.control_volume.properties_in[0.0].flow_vol*(fs.equil.control_volume.properties_in[0.0].temperature - fs.properties.temperature_ref) - fs.properties.dens_mol*fs.properties.cp_mol*fs.equil.control_volume.properties_out[0.0].flow_vol*(fs.equil.control_volume.properties_out[0.0].temperature - fs.properties.temperature_ref) + fs.equil.control_volume.heat[0.0] + fs.equil.control_volume.heat_of_reaction[0.0] == 0" + ] + } + ], + "source": [ + "print_compact_form(m.fs.equil.control_volume.enthalpy_balances[0.0])" + ] + }, + { + "cell_type": "markdown", + "id": "a7ca07c1", + "metadata": {}, + "source": [ + "Whilst a bit harder to read due to the size of the constraint, you can see that it involves the term $\\rho \\times c_p \\times F \\times (T - T_{ref})$, where $c_p$ is the specific molar heat capacity of the solution, $T$ is temperature and $T_{ref}$ is the reference temperature. Given that $\\rho$ is of order 1E4 (a) and $c_p \\times (T-T_{ref})$ is of order 1E3, this means that the partial derivative with respect to the volumetric flowrate is even larger than that for the H2O balance. This also explains the appearance of the outlet temperature as well, as we can see that it is multiplied by a number of large values as well and thus has a large partial derivative.\n", + "\n", + "It is also important to mention that having a large value in the Jacobian does not mean a variable is \"important\" (and conversely a small value is not unimportant). What is important is how sensitive the constraint residual is to that change in variable, which is often difficult to assess from the Jacobian alone (which is where the ``SVDToolbox`` can assist).\n", + "\n", + "\n", + "## Step 3: Creating a New Scaler Class\n", + "\n", + "To create a new scaling routine for the equilibrium reactor, we start by creating a new ``Scaler`` class which inherits from the ``CustomScalerBase`` class in ``idaes.core.scaling``. The ``CustomScalerBase`` class contains a number of useful methods to help us in developing our scaling routine, including some placeholder methods for implementing a standard scaling workflow and helper methods for doing common tasks.\n", + "\n", + "The cell below shows how to create our new class which we will name ``EquilibriumReactorScaler`` as well as two key methods we will fill out as part of this workshop." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "43fc4d67", + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.scaling import CustomScalerBase\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "id": "9b4b6638", + "metadata": {}, + "source": [ + "The ``variable_scaling_routine`` and ``constraint_scaling_routine`` methods are used to implement subroutines for scaling the variables and constraints in the model respectively. Separately, there is a ``scale_model`` method that will call each of these in sequence in order to scale an entire model by applying the following steps:\n", + "\n", + "1. apply variable scaling routine,\n", + "2. apply first stage scaling fill-in,\n", + "3. apply constraint scaling routine,\n", + "4. apply second stage scaling fill-in.\n", + "\n", + "The second and fourth steps are intended to allow users to provide methods to fill in missing scaling information that was not provided by the first and second steps, or to provide a way to update the scaling factors with more information.\n", + "\n", + "Both the ``variable_scaling_routine`` and ``constraint_scaling_routine`` are user-facing methods and take three arguments.\n", + "\n", + "1. The model to be scaled.\n", + "2. An argument indicating whether to overwrite any existing scaling factors. Generally we assume that any existing scaling factors were provided by the user for a reason, so by default we set this to ``False``. However, there will likely be cases where a user wants to overwrite their existing scaling factors so this argument exists to let us pass on those instructions.\n", + "3. A mapping of user-provided ``Scalers`` to use when scaling submodels.\n", + "\n", + "## Step 4: Apply Scaling to Sub-Models\n", + "\n", + "First, lets look at how to scale the property and reaction sub-models. As these are modular packages, we do not know what variables and constraints may be in them, so we cannot (and should not) scale any of these directly. However, we can (hopefully) assume that there are ``Scalers`` available for these sub-models, either through default ``Scalers`` associated with the property packages or provided by the user. Thus, what we want to do here is to call the variable and constraint scaling routines from the ``Scaler`` associated with each sub-model, which we can do using the ``call_submodel_scaler_method`` method from the ``CustomScalerBase`` class.\n", + "\n", + "The cell below prints the doc-string for the ``call_submodel_scaler_method`` method so we can see what the expected arguments are." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "b0363a58", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Help on function call_submodel_scaler_method in module idaes.core.scaling.custom_scaler_base:\n", + "\n", + "call_submodel_scaler_method(self, submodel, method: str, submodel_scalers: pyomo.common.collections.component_map.ComponentMap = None, overwrite: bool = False)\n", + " Call scaling method for submodel.\n", + " \n", + " Scaler for submodel is taken from submodel_scalers if present, otherwise the\n", + " default scaler for the submodel is used.\n", + " \n", + " Args:\n", + " submodel: submodel to be scaled\n", + " submodel_scalers: user provided ComponentMap of Scalers to use for submodels\n", + " method: name of method to call from submodel (as string)\n", + " overwrite: whether to overwrite existing scaling factors\n", + " \n", + " Returns:\n", + " None\n", + "\n" + ] + } + ], + "source": [ + "help(CustomScalerBase.call_submodel_scaler_method)" + ] + }, + { + "cell_type": "markdown", + "id": "4eb930d6", + "metadata": {}, + "source": [ + "We can see that ``call_submodel_scaler_method`` takes 4 arguments:\n", + "\n", + "1. ``submodel`` is the submodel we want to scale. \n", + "2. The ``submodel_scalers`` argument should be passed through from the ``variable_scaling_routine`` or ``constraint_scaling_routine`` method.\n", + "3. The name of the method we want to call from the ``Scaler`` when we get it - this will normally be either ``variable_scaling_routine`` (if we are scaling variables) or ``constraint_scaling_routine`` (if we are doing constraints).\n", + "4. The ``overwrite`` argument should also be passed through from the ``variable_scaling_routine`` or ``constraint_scaling_routine`` method.\n", + "\n", + "For the Equilibrium Reactor, we have three submodels to scale; inlet state, outlet state and reactions. As mentioned in the introduction, when developing scaling routines always start with the things you have the most information about. In this case, we likely know the most about the inlet state; either it is a defined feed state (like in our test case) or we have some idea of the state (and scaling) from propagating values from an upstream operation. So, to apply variable scaling to the inlet state we would do the following:\n", + "\n", + "```python\n", + "self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + ")\n", + "```\n", + "\n", + "Once we have an idea of scaling for the inlet we can use that information to try to estimate scaling for the outlet state. The default assumption is that the scaling of the outlet will be similar to that of the inlet, so the easy path is to copy scaling from the inlet state to the outlet. However, we know that something must change between inlet and outlet (as otherwise this unit operation is doing nothing) so we should always stop and think about whether we can try to estimate these changes. For example, in a pressure changer we know, or be able to estimate, the pressure change across the unit and thus be able to change the scaling of pressure between the inlet and outlet. However, keep in mind that over-scaling can make things worse so be judicious when deciding whether to adjust scaling based on estimates.\n", + "\n", + "In regards to this, Equilibrium Reactors are one of the more challenging units to scale, as it is very hard to know what the outlet flows and concentrations will be without knowing what the reactions are (and even if you know the reactions it is often hard to know the equilibrium state). In most cases, we have no reliable way to estimate the outlet flowrate and concentrations, so this is best left to the user to provide. In the case of temperature and pressure, whilst we may expect these to change but any change will generally be 1-2 orders of magnitude less than the inlet state and thus the overall scale of these will likely remain similar. Thus, for the Equilibrium Reactor it is probably sufficient to just scale the outlet state based on the inlet state.\n", + "\n", + "The ``CustomScalerBase`` class has a method for propagating scaling factors for state variables from one state to another called ``propagate_state_scaling`` as see below." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "aa684f2e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Help on function propagate_state_scaling in module idaes.core.scaling.custom_scaler_base:\n", + "\n", + "propagate_state_scaling(self, target_state, source_state, overwrite: bool = False)\n", + " Propagate scaling of state variables from one StateBlock to another.\n", + " \n", + " Indexing of target and source StateBlocks must match.\n", + " \n", + " Args:\n", + " target_state: StateBlock to set scaling factors on\n", + " source_state: StateBlock to use as source for scaling factors\n", + " overwrite: whether to overwrite existing scaling factors\n", + " \n", + " Returns:\n", + " None\n", + "\n" + ] + } + ], + "source": [ + "help(CustomScalerBase.propagate_state_scaling)" + ] + }, + { + "cell_type": "markdown", + "id": "986b238c", + "metadata": {}, + "source": [ + "Here we can see that ``propagate_state_scaling`` takes three arguments; the ``StateBlock`` we want to apply scaling to, the ``StateBlock`` we want to use as the source for the scaling factors, and the ``overwrite`` argument. Thus, we can propagate scaling from the inlet state to the outlet state as shown below.\n", + "\n", + "```python\n", + "self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + ")\n", + "```\n", + "\n", + "This only propagates scaling factors for the state variables, however, so we should then call the ``Scaler`` for the outlet state block to scale any remaining variables and constraints (which will hopefully make use of the scaling factors for the state variables we just propagated).\n", + "\n", + "We can then move on to scaling the ``ReactionBlock``. ``ReactionBlocks`` are slightly unusual in that they rely heavily on the state variables defined in a separate ``StateBlock`` - in this case the outlet state block. As we just applied a ``Scaler`` to the outlet state block, we can assume that all of the necessary variables have been scaled so all we need to do now is call a ``Scaler`` for the ``ReactionBlock``.\n", + "\n", + "All of this is shown in the cell below." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "f845d90e", + "metadata": {}, + "outputs": [], + "source": [ + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "id": "1081f653", + "metadata": {}, + "source": [ + "We can then take a similar approach for the constraint scaling routine as shown below. Note that there is no need for a propagation step here as the residual of a constraint is derived from the value of the variables (which we handled in the variable scaling step)." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "cbad67e9", + "metadata": {}, + "outputs": [], + "source": [ + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "ebbb37b9", + "metadata": {}, + "source": [ + "Lets do a quick check to see if our new scaler works and how it has affected the model scaling. The cell below creates a function that builds a new instance of the model (to avoid contamination from previous model runs then creates an instance of our new scaler and applies it to the model. We then solve the scaled model (adding scaling changes constraint residuals so we want to solve to the scaled state). Finally, the function prints a report of the scaling factors in the model and calls the ``report_numerical_issues`` method from the Diagnostics Toolbox." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "c67fa218", + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import check_optimal_termination, TransformationFactory\n", + "\n", + "from idaes.core.scaling import report_scaling_factors\n", + "\n", + "\n", + "def check_scaling(tee=False):\n", + " # Build new instance of model\n", + " m = build_model()\n", + "\n", + " # Apply scaler to model\n", + " scaler = EquilibriumReactorScaler()\n", + " scaler.scale_model(m.fs.equil)\n", + "\n", + " # Solve scaled model\n", + " results = solver.solve(m, tee=tee)\n", + " if check_optimal_termination(results):\n", + " print(\"\\nModel Solved\\n\")\n", + " else:\n", + " print(\"\\nModel Failed to Converge!\\n\")\n", + "\n", + " # Print report of scaling factors\n", + " report_scaling_factors(m.fs.equil, descend_into=True)\n", + "\n", + " # Show numerical issues report\n", + " sm = TransformationFactory(\"core.scale_model\").create_using(m, rename=False)\n", + "\n", + " dt = DiagnosticsToolbox(model=sm.fs.equil)\n", + " dt.report_numerical_issues()" + ] + }, + { + "cell_type": "markdown", + "id": "75a9e012", + "metadata": {}, + "source": [ + "Lets run the ``check_scaling`` function and see how the model scaling has changed." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "781f06d0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.enthalpy_balances[0.0] None\n", + "fs.equil.control_volume.pressure_balance[0.0] None\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.022E+09\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "5 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "markdown", + "id": "01a74363", + "metadata": {}, + "source": [ + "From the scaling factor report, we can see that by calling the submodel scalers we have already scaled many of the variables in our problem, as well as three of the constraints. If we look at the \"Scaled Value\" column for the variables, we can also see that most of the scaled values are close to 1 (the few outliers might be things we want to look into more later on).\n", + "\n", + "From the numerical diagnostics, we can see that the Jacobian condition number has decreased by a few orders of magnitude, although it is still large, whilst we still have a number of potential issues with individual variables and constraints. All up though, this appears to be a step in the right direction." + ] + }, + { + "cell_type": "markdown", + "id": "f543a9a3", + "metadata": {}, + "source": [ + "## Step 5: Apply Variable Scaling\n", + "\n", + "Next, we need to look at scaling the variables and constraints that make up the unit model itself. From a conceptual standpoint, it is generally easiest to start with the variables as we generally have at least some idea of the magnitude of these.\n", + "\n", + "For the equilibrium reactor, we have the following variables we need to scale:\n", + "\n", + "1. Rate-based reaction extent and generation terms\n", + "2. Equilibrium-based reaction extent and generation terms\n", + "3. Inherent reaction extent and generation terms\n", + "4. Phase equilibrium generation terms\n", + "5. Energy balance heat term\n", + "6. Energy balance heats of reaction\n", + "7. Pressure drop\n", + "\n", + "Many of these are hard to know a priori - anything related to a reaction is very hard to know without knowing the reaction behavior. Considering that the equilibrium reactor is modular, we have little to no way of knowing these in the general case (and even in the specific test case it is hard enough). We can assume that the reaction package will scale all of its variables (i.e., rate and equilibrium constants, and reaction rates), however it is hard to project these to unit model scaling.\n", + "\n", + "For a CSTR we can say that ``extent = volume*rate`` and thus estimate scaling, but this does not work for equilibrium systems where 1) volume is undefined, 2) reaction rate at the outlet state is being driven to zero to satisfy equilibrium, and 3) extent is solved implicitly to satisfy the need for reaction rate to equal zero.\n", + "\n", + "Considering that a bad guess is often worse than no guess, we will not scale these right now - it is important to remember that our goal is to improve the overall scaling so if we do not know how to scale something it is generally best to leave it unscaled. We might come back to these later if necessary, but for now we will leave these either for the user to provide based on knowledge of their system, or for automated fill-in using some autoscaler.\n", + "\n", + "For the heat and deltaP terms, these are dependent on extensive variables in each case study and we have no way of knowing their exact values. However, we can probably take a good guess at order-of-magnitude using engineering knowledge; heat duties are generally approximately one order of magnitude smaller than the enthalpy flows,\n", + "and pressure drops are generally on the order of 0.1 bar.\n", + "\n", + "To apply scaling for the pressure drop term, we can make use of the ``scale_variable_by_units`` method in ``CustomScalerBase``. This method looks up the units of measurement for the variable, and then loops in the class attribute ``UNIT_SCALING_FACTORS`` dictionary to find an equivalent unit for the quantity of interest and an associated scaling factor. If a scaling factor is found, it is converted as necessary; e.g., in this case pressure is defined in ``Pa`` but we can set the default scaling factor in ``bar`` and it will be converted as appropriate. The code required to do this is below.\n", + "\n", + "```python\n", + "UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + "}\n", + "\n", + "def variable_scaling_routine(*args, **kwargs):\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t],\n", + " overwrite=overwrite\n", + " )\n", + "```\n", + "\n", + "There are a few things to note here:\n", + "\n", + "1. As we expect the pressure drop to be on the order of 0.1 bar, we need to set a scaling factor of 10 for quantities with units of pressure. Also note that the key ``\"Pressure Change\"`` is for documentation purposes only and is not actually used by the code (but must be there). \n", + "\n", + "
\n", + "NOTE We cannot distinguish between different quantities with the same apparent units (e.g., we cannot distinguish between an absolute pressure and a pressure change).\n", + "
\n", + "\n", + "2. Note that scaling is applied to elements of indexed components and not to the indexed component as a whole, and thus we need to use a ``for`` loop to iterate over the time index. This is done to force modelers to consider how the scaling of a variable or constraint will vary over the indexed domain, and try to discourage automatically setting a single scaling factor for all points.\n", + "3. Pressure change is a configuration argument in our unit model, and thus may not be present in all cases. Therefore, we need the ``hasattr`` check to see if we need to scale ``deltaP`` or not.\n", + "\n", + "For the case of the heat duty, we want to scale based on the incoming enthalpy flow which means we first need to get the expected magnitude of the enthalpy flow. For that, we can use the ``get_expression_nominal_values`` method in ``CustomScalerBase`` which uses an expression walker to go through an expression to return a list of the expected magnitude (or nominal value) of all additive terms in the expression based on the scaling factors for the variables involved.\n", + "\n", + "We can get an expression for the enthalpy flow term using the ``get_enthalpy_flow_terms`` method from the associated ``StateBlock``. We should assume this expression might contain multiple terms, so we should sum all the values returned to get the overall magnitude of the enthalpy flow term. Once we have this, we can then get the scaling factor for the heat duty by ``sf = abs(1/(0.1*enthalpy_flow))`` - note that the tools insist on scaling factors being positive (for sanity) and thus we need the absolute value here in case enthalpy flow is negative (which is not uncommon for enthalpy). The code to do this is shown below.\n", + "\n", + "```python\n", + "if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[t].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is general one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(model.control_volume.heat[t], abs(1 / (0.1 * h_in)))\n", + "```\n", + "\n", + "Putting all of this together results in the code below for our ``EquilibriumReactorScaler`` class." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "a1ae2667", + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import units\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " # =======================================================================================\n", + " # New Code\n", + " UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + " }\n", + " # =======================================================================================\n", + "\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # =======================================================================================\n", + " # New Code\n", + "\n", + " # Pressure drop - optional\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t], overwrite=overwrite\n", + " )\n", + "\n", + " # Heat transfer - optional\n", + " # Scale heat based on enthalpy flow entering reactor\n", + " if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[\n", + " t\n", + " ].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is generally one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(\n", + " model.control_volume.heat[t], abs(1 / (0.1 * h_in))\n", + " )\n", + " # =======================================================================================\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "c9aff0df", + "metadata": {}, + "source": [ + "Once again, lets run the ``check_scaling`` function and see how we are going." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "5f8e0e6f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] 4.794E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] 1.000E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.enthalpy_balances[0.0] None\n", + "fs.equil.control_volume.pressure_balance[0.0] None\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.022E+09\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "5 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "markdown", + "id": "9622e561", + "metadata": {}, + "source": [ + "Our updates have resulted in scaling factors for ``heat`` and ``deltaP`` appearing in the scaling report which is good, but comparing the diagnostics from the previous step we can see that the Jacobian condition number has not changed. Does this mean we did something wrong?\n", + "\n", + "The answer is no - when we add a scaling factor to a variable, wherever that variable appears in a constraint it is replaced with ``sf*v_scaled``. Given that ``v_scaled = v/sf``, this means that for variables which only appear linearly in constraints then the partial derivative with respect to the scaled variable does not change either; thus the Jacobian is unaffected by scaling only the linear variables. In the case of this example, it turns out that almost all the variables appear linearly and thus we see no change in the Jacobian condition number.\n", + "\n", + "
\n", + "NOTE It is important to note that partial scaling of a model (e.g., variables only) can often appear worse than that of the unscaled model. Generally, it is best to wait until you have scaled both variables and constraints to make a decision on whether your attempts at scaling have made the problem better or worse, and you should not be discouraged if things look worse while in an intermediate state.\n", + "
\n", + "\n", + "\n", + "## Step 6: Apply Constraint Scaling\n", + "\n", + "Now that we have scaled all the variables that we can (for now at least), we can move on to scaling constraints. The advantage of scaling all the variables first means that now we have an idea of the expected magnitude for all terms in the constraints which we can use to estimate scaling factors. For the Equilibrium reactor model, we need to scale all the constraints in the control volume, as well as the unit level constraint equating all reaction rates to zero.\n", + "\n", + "There are many approaches to estimating scaling for constraints, and different approaches are better suited to certain situations. ``CustomScalerBase`` contains a ``scale_constraint_by_nominal_value`` method which can be used to automatically implement a number of common approaches to save you the effort of having to manually implement these yourself. As of writing, the approaches (or schemes) supported are:\n", + "\n", + "1. ``ConstraintScalingScheme.inverseMaximum`` - scale the constraint based on the term with the largest absolute expected magnitude. This is scheme is useful for cases where most terms have similar magnitudes and is a good initial point to start.\n", + "2. ``ConstraintScalingScheme.inverseMinimum`` - scale the constraint based on the term with the smallest absolute expected magnitude. This scheme is similar to the inverse maximum scheme and is useful for cases where you have a constraint with a number of smaller terms mixed with a few larger terms, or cases where the smaller term is expected to be most significant. This scheme should be used carefully however as it can result in large scaling factors making convergence of larger terms difficult.\n", + "3. ``ConstraintScalingScheme.harmonicMean`` - scale the constraint using the harmonic mean of the absolute expected magnitude of all terms (``sf = sum(1/abs(nominal value))``). This scheme is most useful when you have a constraint with terms with a mix of expected magnitudes where you need to find a balance between the large and small terms.\n", + "4. ``ConstraintScalingScheme.inverseSum`` - scale the constraint using the sum of the absolute expected magnitudes of all terms. Situationally useful for cases with terms of mixed magnitudes.\n", + "5. ``ConstraintScalingScheme.inverseRSS`` - scale the constraint using the root sum of squares of the absolute expected magnitudes of all terms. Situationally useful for cases with terms of mixed magnitudes.\n", + "\n", + "``CustomScalerBase`` also contains a ``scale_constraint_by_nominal_derivative_norm`` method that can scale a constraint based on an estimate of the Jacobian norm associated with that constraint which can be useful for cases where you want to focus on the Jacobian scaling.\n", + "\n", + "
\n", + "NOTE The solver you intend to use may impact which approach provides the best scaling for a given model. For example, IPOPT has very good internal Jacobian scaling (when using the `gradient-based` scaling option), and thus benefits the most from focusing on scaling the constraint residual magnitudes as opposed to the Jacobian.\n", + "
\n", + "\n", + "For this workshop, we will start by just using ``ConstraintScalingScheme.inverseMaximum`` to get a starting point and to see if further scaling is required. We can apply this scheme to scale all the constraints in the control volume using the code below.\n", + "\n", + "```python\n", + "for c in model.control_volume.component_data_objects(\n", + " Constraint, descend_into=False\n", + "):\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + "```\n", + "\n", + "Adding this and a similar approach to scale the unit level constraint gives us the code below for our ``EquilibriumreactorScaler`` class." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "389332f0", + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.scaling import ConstraintScalingScheme\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + " }\n", + "\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # Pressure drop - optional\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t], overwrite=overwrite\n", + " )\n", + "\n", + " # Heat transfer - optional\n", + " # Scale heat based on enthalpy flow entering reactor\n", + " if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[\n", + " t\n", + " ].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is generally one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(\n", + " model.control_volume.heat[t], abs(1 / (0.1 * h_in))\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # =======================================================================================\n", + " # New Code\n", + " # Scale control volume constraints\n", + " for c in model.control_volume.component_data_objects(\n", + " Constraint, descend_into=False\n", + " ):\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # Scale unit level constraints\n", + " if hasattr(model, \"rate_reaction_constraint\"):\n", + " for c in model.rate_reaction_constraint.values():\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + " # =======================================================================================" + ] + }, + { + "cell_type": "markdown", + "id": "242dd26c", + "metadata": {}, + "source": [ + "Once again, let us use the ``check_scaling`` function to see how our ``Scaler`` performs." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "75d11627", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] 4.794E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] 1.000E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] 1.000E+02\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] 1.000E+00\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] 1.000E+01\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] 1.000E-02\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] 1.000E+00\n", + "fs.equil.control_volume.enthalpy_balances[0.0] 7.715E-08\n", + "fs.equil.control_volume.pressure_balance[0.0] 9.869E-06\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 7.182E+04\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "3 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 2 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "77fe3766", + "metadata": { + "tags": [ + "testing" + ] + }, + "outputs": [], + "source": [ + "# Build a new instance of the model for testing\n", + "test_model = build_model()\n", + "scaler = EquilibriumReactorScaler()\n", + "scaler.scale_model(test_model.fs.equil)\n", + "\n", + "# Check that the number of scaling factors assigned matches expectations\n", + "# We will assume they were set correctly\n", + "assert len(test_model.fs.equil.scaling_factor) == 1\n", + "assert len(test_model.fs.equil.control_volume.scaling_factor) == 14\n", + "assert len(test_model.fs.equil.control_volume.properties_in[0].scaling_factor) == 8\n", + "assert len(test_model.fs.equil.control_volume.properties_out[0].scaling_factor) == 9\n", + "assert len(test_model.fs.equil.control_volume.reactions[0].scaling_factor) == 4" + ] + }, + { + "cell_type": "markdown", + "id": "35e8d1ad", + "metadata": {}, + "source": [ + "From the results of ``check_scaling`` we can see that we now have scaling factors for almost all the variables and constraints in the model (the only exceptions being the reaction related variables we left unscaled earlier). More importantly, we can see that the Jacobian condition number is now down to ``7.2E4`` from the original ``1.5E12`` which is an impressive improvement (and for not a lot of effort on our part). We can also see that the numerical diagnostics are no longer reporting any variables or constraints with extreme Jacobians (there are 2 individual entries that are a bit large, but it appears they are not having a big impact on the condition number).\n", + "\n", + "We do see that there are a number of variables with values close to ``0`` which we should be wary of, but in this case it is due to the case study we are using. Here we are using an equilibrium reactor to drive a rate-based reaction to completion, which necessitates that at least one reactant have a concentration of zero as well as the reaction rate for all reactions. Thus, for this case these are unavoidable. As mentioned earlier, we really should be asking whether an Equilibrium Reactor is well suited for the reaction model we have here, and a Stoichiometric Reactor would probably have been a better choice (or a better reaction package which use reversible reactions with equilibrium).\n", + "\n", + "\n", + "## Step 7: Review Scaling Routine\n", + "\n", + "We now have a new ``Scaler`` for an equilibrium reactor that uses the modular nature of IDAES to implement a general purpose scaling routine (or so we hope at least). So, does this mean we are done?\n", + "\n", + "No, or not yet at least.\n", + "\n", + "We should always take a step back and ask ourselves if what we have is good enough and see if we can see any areas where we might be able to do better, or places where edge cases might exist. As a starting point, let us first see how we compare to an autoscaling routine using the model Jacobian. We can use the ``AutoScaler.scale_model`` method for this as shown below." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "80e47573", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: model contains export suffix 'scaling_factor' that contains 10\n", + "component keys that are not exported as part of the NL file. Skipping.\n", + "\n", + "Model Solved\n", + "\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.863E+06\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "4 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 2 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 7 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "from idaes.core.scaling import AutoScaler\n", + "\n", + "m = build_model()\n", + "\n", + "scaler = EquilibriumReactorScaler()\n", + "autoscaler = AutoScaler()\n", + "\n", + "autoscaler.scale_model(m)\n", + "\n", + "solver = get_solver(\n", + " \"ipopt_v2\", writer_config={\"scale_model\": True, \"linear_presolve\": True}\n", + ")\n", + "results = solver.solve(m)\n", + "\n", + "if check_optimal_termination(results):\n", + " print(\"\\nModel Solved\\n\")\n", + "else:\n", + " print(\"\\nModel Failed to Converge!\\n\")\n", + "\n", + "sm = TransformationFactory(\"core.scale_model\").create_using(m, rename=False)\n", + "\n", + "dt = DiagnosticsToolbox(model=sm.fs.equil)\n", + "dt.report_numerical_issues()" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "dfec0c62", + "metadata": { + "tags": [ + "testing" + ] + }, + "outputs": [], + "source": [ + "# Test to ensure autoscaled model solved\n", + "assert check_optimal_termination(results)" + ] + }, + { + "cell_type": "markdown", + "id": "522076eb", + "metadata": {}, + "source": [ + "We can see that our ``EquilibriumReactorScaling`` routine actually results in a lower Jacobian condition number than the ``AutoScaler`` approach, so that is a sign we are doing things right. It is not unusual to see that we can get better scaling with a manual, magnitude based approach than an autoscaler as the autoscaler focuses solely on the Jacobian and thus often over-scales the problem.\n", + "\n", + "However, we might be able to do better by using other constraint scaling schemes, but before we start experimenting we should stop and think about what sort of scaling might make sense for each constraint. We should always also keep in the back of our minds whether additional work is worth the effort, and if we risk over-tuning the scaling for the specific property package we have.\n", + "\n", + "Fortunately, the model in this example is fairly simple and we do not have too many constraints to consider. Firstly, we have the unit-level constraint that says that `rate_reaction == 0` for all rate-based reactions. When considering scaling of a constraint we should ignore any 0 terms, thus this constraint has only 1 term and so we should scale based on this. If we use the ``scale_constraint_by_nominal_value`` method for this it will ignore the zero for us, the scheme used does not actually matter as there is only one term to consider.\n", + "\n", + "Next, we have the balance equations which all have the form `0 == In - Out + Gen` - note the equilibrium reactor does not support dynamics so we don't need to think about that. Generation terms can vary a lot, but we basically have two possible cases:\n", + "\n", + "1. one term is negligible compared to the other 2, so we should scale based on one of the significant\n", + "terms, or\n", + "2. all three terms are of similar significance (e.g., inlet and gen are of similar scale and outlet\n", + "is ~inletx2). Here we could scale based on the harmonic mean, by the maximum term is probably not bad either.\n", + "\n", + "So, in short the maximum magnitude is probably the best general-purpose scale for these constraints.\n", + "\n", + "Finally, we have stoichiometric constraints with the form `G[j, r] == n[j, r]*X[r]` where ``G`` is generation, ``X`` is extent and ``n`` is the stoichiometric coefficient (i.e., a constant) - these are simple ``A=B`` constraints, so scaling by maximum magnitude is equivalent to other methods (as there are only two terms which will take the same value, all schemes will give the same result in the end).\n", + "\n", + "So, for the equilibrium reactor at least, we are probably best leaving things as they are.\n", + "\n", + "However, there is one important test left. The whole purpose of a scaling routine is to allow us to perturb the model and solve it at the new state so we should test to confirm that our new ``Scaler`` has improved the performance of our solver when solving for the perturbed state we tried earlier. This also lets us see how the new ``Scaler`` will look for a user trying to apply the tool, which we can see below." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "a5d82e55", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Ipopt 3.13.2: linear_solver=ma57\n", + "max_iter=200\n", + "nlp_scaling_method=gradient-based\n", + "tol=1e-06\n", + "\n", + "\n", + "******************************************************************************\n", + "This program contains Ipopt, a library for large-scale nonlinear optimization.\n", + " Ipopt is released as open source code under the Eclipse Public License (EPL).\n", + " For more information visit http://projects.coin-or.org/Ipopt\n", + "\n", + "This version of Ipopt was compiled from source code available at\n", + " https://github.com/IDAES/Ipopt as part of the Institute for the Design of\n", + " Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE\n", + " Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.\n", + "\n", + "This version of Ipopt was compiled using HSL, a collection of Fortran codes\n", + " for large-scale scientific computation. All technical papers, sales and\n", + " publicity material resulting from use of the HSL codes within IPOPT must\n", + " contain the following acknowledgement:\n", + " HSL, a collection of Fortran codes for large-scale scientific\n", + " computation. See http://www.hsl.rl.ac.uk.\n", + "******************************************************************************\n", + "\n", + "This is Ipopt version 3.13.2, running with linear solver ma57.\n", + "\n", + "Number of nonzeros in equality constraint Jacobian...: 21\n", + "Number of nonzeros in inequality constraint Jacobian.: 0\n", + "Number of nonzeros in Lagrangian Hessian.............: 9\n", + "\n", + "Total number of variables............................: 8\n", + " variables with only lower bounds: 5\n", + " variables with lower and upper bounds: 1\n", + " variables with only upper bounds: 0\n", + "Total number of equality constraints.................: 8\n", + "Total number of inequality constraints...............: 0\n", + " inequality constraints with only lower bounds: 0\n", + " inequality constraints with lower and upper bounds: 0\n", + " inequality constraints with only upper bounds: 0\n", + "\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 0 0.0000000e+00 5.53e+02 1.00e+00 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0\n", + "Reallocating memory for MA57: lfact (247)\n", + " 1 0.0000000e+00 5.53e+02 1.20e+00 -1.0 9.95e+02 - 2.00e-05 1.96e-05h 1\n", + " 2 0.0000000e+00 5.53e+02 1.20e+00 -1.0 9.57e+02 - 2.06e-05 2.00e-05h 1\n", + " 3 0.0000000e+00 5.53e+02 7.36e+01 -1.0 9.25e+02 - 4.36e-04 4.06e-05h 1\n", + " 4 0.0000000e+00 5.53e+02 3.34e+05 -1.0 8.55e+02 - 2.41e-04 1.21e-03f 1\n", + " 5 0.0000000e+00 5.40e+02 6.59e+03 -1.0 9.98e+01 - 2.25e-04 2.34e-02f 1\n", + " 6 0.0000000e+00 5.24e+02 1.11e+08 -1.0 9.74e+01 - 2.54e-02 2.84e-02f 1\n", + " 7 0.0000000e+00 2.36e+02 2.03e+06 -1.0 9.47e+01 - 7.09e-02 5.49e-01h 1\n", + " 8 0.0000000e+00 8.62e+01 6.37e+10 -1.0 4.27e+01 - 8.23e-03 6.35e-01h 1\n", + " 9 0.0000000e+00 1.96e+00 4.93e+10 -1.0 1.56e+01 - 1.00e+00 1.00e+00h 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 10 0.0000000e+00 2.05e-04 2.15e+09 -1.0 3.70e-02 - 1.00e+00 1.00e+00h 1\n", + " 11 0.0000000e+00 6.28e-15 2.56e+05 -1.0 2.05e-04 - 1.00e+00 1.00e+00h 1\n", + "\n", + "Number of Iterations....: 11\n", + "\n", + " (scaled) (unscaled)\n", + "Objective...............: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Dual infeasibility......: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Constraint violation....: 6.2780236478193237e-15 6.2780236478193237e-15\n", + "Complementarity.........: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Overall NLP error.......: 6.2780236478193237e-15 6.2780236478193237e-15\n", + "\n", + "\n", + "Number of objective function evaluations = 12\n", + "Number of objective gradient evaluations = 12\n", + "Number of equality constraint evaluations = 12\n", + "Number of inequality constraint evaluations = 0\n", + "Number of equality constraint Jacobian evaluations = 12\n", + "Number of inequality constraint Jacobian evaluations = 0\n", + "Number of Lagrangian Hessian evaluations = 11\n", + "Total CPU secs in IPOPT (w/o function evaluations) = 0.000\n", + "Total CPU secs in NLP function evaluations = 0.000\n", + "\n", + "EXIT: Optimal Solution Found.\n" + ] + } + ], + "source": [ + "m = build_model()\n", + "\n", + "scaler = EquilibriumReactorScaler()\n", + "scaler.scale_model(m.fs.equil)\n", + "\n", + "perturb_model(m)" + ] + }, + { + "cell_type": "markdown", + "id": "6d324d79", + "metadata": {}, + "source": [ + "We can see that by applying our new ``EquilibriumReactorScaler`` we are now able to use IPOPT to solve for the perturbation, and that it reaches an optimal solution in 11 iterations. Looking at the solver logs we can see that the solver step lengths (``alpha_du`` and ``alpha_pr``) are rather small for the first iterations but the number of line searches (``ls``) is 1 for all iterations. This indicates that IPOPT is pushing up against some bound or constraint and cannot make full steps, but in this case it is due to the fact that to achieve equilibrium for an irreversible reaction at least one concentration must be driven to zero (and is why an EquibriumReactor is probably not a good choice for this test case). However, the fact that our ``Scaler`` let us solve for this challenging test case is probably a good sign.\n", + "\n", + "\n", + "## Step 8: Finishing Up\n", + "\n", + "Ideally, we would have more than one test case to apply our ``Scaler`` to put it through its paces and ensure it is robust across a wide range of conditions. However, for the purposes of this workshop we will move on.\n", + "\n", + "Once you are satisfied that your ``Scaler`` is ready, you can start applying it to actual problems of interest. For those modelers developing new unit and property models, you should assign your new ``Scaler`` as the default scaler for that unit model. You can do this by setting the ``default_scaler`` attribute on your model to point to the new ``Scaler`` as shown below.\n", + "\n", + "```python\n", + "@declare_process_block_class(\"EquilibriumReactor\")\n", + "class EquilibriumReactorData(UnitModelBlockData):\n", + " \"\"\"\n", + " Standard Equilibrium Reactor Unit Model Class\n", + " \"\"\"\n", + "\n", + " # Setting the default_scaler attribute\n", + " default_scaler = EquilibriumReactorScaler\n", + "```\n", + "\n", + "With that, we have finished this workshop on developing ``Scaler`` classes. Hopefully you now know enough to begin writing ``Scalers`` for your own models, and have gained some insight into how to think about developing scaling routines and the tools available to help you." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "c03a51d5", + "metadata": { + "tags": [ + "testing" + ] + }, + "outputs": [], + "source": [ + "# Test that scaled model re-solves\n", + "results = solver.solve(m)\n", + "assert check_optimal_termination(results)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "682a983a", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "celltoolbar": "Tags", + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/idaes_examples/notebooks/docs/scaling/scaler_workshop_doc.ipynb b/idaes_examples/notebooks/docs/scaling/scaler_workshop_doc.ipynb new file mode 100644 index 00000000..a7ba87bb --- /dev/null +++ b/idaes_examples/notebooks/docs/scaling/scaler_workshop_doc.ipynb @@ -0,0 +1,1912 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [ + "header", + "hide-cell" + ] + }, + "outputs": [], + "source": [ + "###############################################################################\n", + "# The Institute for the Design of Advanced Energy Systems Integrated Platform\n", + "# Framework (IDAES IP) was produced under the DOE Institute for the\n", + "# Design of Advanced Energy Systems (IDAES).\n", + "#\n", + "# Copyright (c) 2018-2023 by the software owners: The Regents of the\n", + "# University of California, through Lawrence Berkeley National Laboratory,\n", + "# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon\n", + "# University, West Virginia University Research Corporation, et al.\n", + "# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md\n", + "# for full copyright and license information.\n", + "###############################################################################" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to Create Scaler Objects in IDAES\n", + "\n", + "Author: Andrew Lee\n", + "Maintainer: Doug Allan\n", + "Updated: 2024-10-24\n", + "\n", + "## Introduction\n", + "\n", + "
\n", + "NOTE All the suggestions in this introduction should be viewed as \"rules-of-thumb\" and not taken as absolute guidance. There are many cases where alternative approaches may give as-good or better results and you should always consider the meaning of the scaling factors you are applying and how they affect the solver's behavior. \n", + "
\n", + "\n", + "Solving general non-linear problems has always been challenging, and is highly dependent on how well scaled the model is. In many cases, as much time (or more) is spent trying to improve the model formulation and scaling as was spent writing the original model. To assist molders with this task, IDAES has implemented a Scaling Toolbox which contains a number of useful tools for common scaling techniques as well as a standard interface and form for how to write scaling routines.\n", + "\n", + "The goal of this workshop is to take you through the process of writing a general-purpose, modular scaling routine for an equilibrium reactor example. By the end of this exercise you should:\n", + "\n", + "* understand the ``CustomScalerBase`` class and how to apply the tools it contains,\n", + "* understand how to use ``CustomScalerBase`` to set up a modular scaling routine for a model,\n", + "* understand how to use the Diagnostics Toolbox to check for scaling issues in a model.\n", + "\n", + "## How to Write a Scaling Routine\n", + "\n", + "
The golden rule when developing a scaling routine to a model is to always think about what you are doing and why. Bad scaling is often worse than no scaling at all, so assigning arbitrary scaling factors should be avoided. Always start by taking the time to look over the model you want to scale and understand what variables and constraints are present. For variables, you should ask yourself what the expected range of magnitudes will be; assigning an arbitrary default value should be avoided. For constraints you should ask yourself what the expected magnitude of each additive term will be, how much these vary from each other, and which term is likely to be most significant in terms of variation (partial derivatives).
\n", + "\n", + "
\n", + "NOTE Different solvers behave in different ways, and you may find cases where tuning scaling for one solver results in worse performance for another.\n", + " \n", + "You should always consider the end-goal when writing a Scaler; if you are writing a routine for a specific application and solver then you may wish to tune the scaling factors for best performance, however if you are writing a general-purpose Scaler then you should aim for scaling that will work for a wide range of conditions and solver.\n", + "
\n", + "\n", + "Below are some general suggestions for developing scaling routines.\n", + " \n", + "* Order of magnitude estimates are generally good enough (and often better than exact values).\n", + "* Start with what you know the most about, and work out from there.\n", + "* If in doubt, start by scaling variables first, and then scale constraints based on the variable scaling.\n", + "* Be judicious when applying scaling factors for things you are uncertain about. If in doubt, leave a component unscaled and see what the model diagnostics have to say.\n", + "* Make use of the modular nature of IDAES when writing scaling routines. A unit model developer might not know the expected magnitude of the thermophysical properties they get from a property package, but there should be a scaling routine for the property package that they can call to provide these.\n", + "\n", + "
\n", + "NOTE When dealing with systems of partial differential algebraic equations (PDAEs), such as dynamic systems or those with spatial variation, it is important to consider how scaling may change across the discretized domain. In many of these types of models, you will find significant changes in scale across a small portion of the domain; for example a dynamic model of a step disturbance will show an initial equilibrium state followed by a rapid change in system conditions until a new equilibrium is established. To complicate things further, the location of this ramp can often move significantly with minor changes in system conditions, thus you should not presume that the ramp will remain in the same place.\n", + " \n", + "As a general rule, for scaling PDAE systems with significant changes, you should focus on finding a set of scaling factors that is suitable for the ramp region as this is the part of the model which will be hardest to solve.\n", + "
\n", + "\n", + "### IDAES Scaling Interface and Toolbox\n", + "\n", + "IDAES uses a class-based interface for defining scaling routines, where model developers can create ``Scaler`` objects which define a scaling routine suitable for a type of model or specific application. All models (both those in the IDAES model libraries and user-developed models) should have one or more ``Scaler`` classes defined for them that can be used to apply scaling routines to the model. To assist end-users in identifying a suitable ``Scaler`` for a model, all IDAES models have a ``default_scaler`` attribute which can be set to point to a ``Scaler`` object suitable for that model. Model developers should endeavor to create a reliable, general-purpose ``Scaler`` for each model they create and assign this as the default ``Scaler``. We will demonstrate how to do this at the end of this workshop.\n", + "\n", + "\n", + "## Step 1: Set Up Test Case(s)\n", + "\n", + "Whilst it is possible to develop a scaling routine by looking only at the model code and the resulting variables and constraints, in order to test it we will need one or more test cases to run. These test cases are important for both checking the that ``Scaler`` code runs as expected, and that it also improves the scaling of the model. The more test cases you can check against, the more confident you can be that the ``Scaler`` you have written is suitable for a wide range of applications.\n", + "\n", + "For this example we will develop a general purpose ``Scaler`` for the ``EquilibriumReactor`` model from the core IDAES model library using the saponification property and reaction packages as a test case. The code below imports the necessary packages and creates a function that will build and initialize our test case." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import ConcreteModel, Constraint, units, Var\n", + "\n", + "from idaes.core import FlowsheetBlock\n", + "from idaes.models.unit_models.equilibrium_reactor import (\n", + " EquilibriumReactor,\n", + ")\n", + "from idaes.models.properties.examples.saponification_thermo import (\n", + " SaponificationParameterBlock,\n", + ")\n", + "from idaes.models.properties.examples.saponification_reactions import (\n", + " SaponificationReactionParameterBlock,\n", + ")\n", + "from idaes.core.solvers import get_solver\n", + "from idaes.core.initialization import BlockTriangularizationInitializer\n", + "from idaes.core.util import DiagnosticsToolbox\n", + "\n", + "\n", + "def build_model():\n", + " m = ConcreteModel()\n", + " m.fs = FlowsheetBlock(dynamic=False)\n", + "\n", + " m.fs.properties = SaponificationParameterBlock()\n", + " m.fs.reactions = SaponificationReactionParameterBlock(\n", + " property_package=m.fs.properties\n", + " )\n", + "\n", + " m.fs.equil = EquilibriumReactor(\n", + " property_package=m.fs.properties,\n", + " reaction_package=m.fs.reactions,\n", + " has_equilibrium_reactions=False,\n", + " has_heat_transfer=True,\n", + " has_heat_of_reaction=True,\n", + " has_pressure_change=True,\n", + " )\n", + "\n", + " m.fs.equil.inlet.flow_vol[0].fix(1.0e-03 * units.m**3 / units.s)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"H2O\"].fix(55388.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"NaOH\"].fix(100.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"EthylAcetate\"].fix(\n", + " 100.0 * units.mol / units.m**3\n", + " )\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"SodiumAcetate\"].fix(0.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"Ethanol\"].fix(0.0 * units.mol / units.m**3)\n", + "\n", + " m.fs.equil.inlet.temperature[0].fix(303.15 * units.K)\n", + " m.fs.equil.inlet.pressure[0].fix(101325.0 * units.Pa)\n", + "\n", + " m.fs.equil.heat_duty.fix(0 * units.W)\n", + " m.fs.equil.deltaP.fix(0 * units.Pa)\n", + "\n", + " initializer = BlockTriangularizationInitializer()\n", + " initializer.initialize(m.fs.equil)\n", + "\n", + " return m" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Before we move on to try to solve the model or develop a ``Scaler``, we should first check to make sure the model is well-posed and that there are not any structural issues that will prevent us from solving the model. The code below creates an instance of the IDAES Diagnostics Toolbox and runs the ``report_structural_issues`` method to ensure there are no warnings." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Activated Blocks: 5 (Deactivated: 0)\n", + " Free Variables in Activated Constraints: 16 (External: 0)\n", + " Free Variables with only lower bounds: 6\n", + " Free Variables with only upper bounds: 0\n", + " Free Variables with upper and lower bounds: 2\n", + " Fixed Variables in Activated Constraints: 10 (External: 0)\n", + " Activated Equality Constraints: 16 (Deactivated: 0)\n", + " Activated Inequality Constraints: 0 (Deactivated: 0)\n", + " Activated Objectives: 0 (Deactivated: 0)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "0 WARNINGS\n", + "\n", + " No warnings found!\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 Cautions\n", + "\n", + " Caution: 4 variables fixed to 0\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " Try to initialize/solve your model and then call report_numerical_issues()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "m = build_model()\n", + "\n", + "dt = DiagnosticsToolbox(model=m.fs.equil)\n", + "dt.report_structural_issues()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# Make sure base model is constructed properly\n", + "dt.assert_no_structural_warnings()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In order to fully test our new ``Scaler`` it is also useful to test how the model responds to perturbations in the state. In many ways, this is the real test of a scaling routine as it is easy to write something that gets good scaling for a known state (e.g., auto-scalers), but what we really need is a routine that can get good scaling across a range of conditions.\n", + "\n", + "The cell below creates a function that perturbs the state of our model significantly. Note that the volumetric flowrate has been increased by two orders of magnitude, the inlet concentrations have changed significantly, and we have also made a small change to the temperature." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.solvers import get_solver\n", + "\n", + "solver = get_solver(\n", + " \"ipopt_v2\", writer_config={\"scale_model\": True, \"linear_presolve\": True}\n", + ")\n", + "\n", + "\n", + "def perturb_model(m):\n", + " m.fs.equil.inlet.flow_vol.fix(1 * units.m**3 / units.s)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"NaOH\"].fix(200.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"EthylAcetate\"].fix(\n", + " 100.0 * units.mol / units.m**3\n", + " )\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"SodiumAcetate\"].fix(50 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"Ethanol\"].fix(1e-8 * units.mol / units.m**3)\n", + "\n", + " m.fs.equil.inlet.temperature.fix(320 * units.K)\n", + " solver.solve(m, tee=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets apply this perturbation to our example model and see how well it solves." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Ipopt 3.13.2: linear_solver=ma57\n", + "max_iter=200\n", + "nlp_scaling_method=gradient-based\n", + "tol=1e-06\n", + "\n", + "\n", + "******************************************************************************\n", + "This program contains Ipopt, a library for large-scale nonlinear optimization.\n", + " Ipopt is released as open source code under the Eclipse Public License (EPL).\n", + " For more information visit http://projects.coin-or.org/Ipopt\n", + "\n", + "This version of Ipopt was compiled from source code available at\n", + " https://github.com/IDAES/Ipopt as part of the Institute for the Design of\n", + " Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE\n", + " Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.\n", + "\n", + "This version of Ipopt was compiled using HSL, a collection of Fortran codes\n", + " for large-scale scientific computation. All technical papers, sales and\n", + " publicity material resulting from use of the HSL codes within IPOPT must\n", + " contain the following acknowledgement:\n", + " HSL, a collection of Fortran codes for large-scale scientific\n", + " computation. See http://www.hsl.rl.ac.uk.\n", + "******************************************************************************\n", + "\n", + "This is Ipopt version 3.13.2, running with linear solver ma57.\n", + "\n", + "Number of nonzeros in equality constraint Jacobian...: 21\n", + "Number of nonzeros in inequality constraint Jacobian.: 0\n", + "Number of nonzeros in Lagrangian Hessian.............: 9\n", + "\n", + "Total number of variables............................: 8\n", + " variables with only lower bounds: 5\n", + " variables with lower and upper bounds: 1\n", + " variables with only upper bounds: 0\n", + "Total number of equality constraints.................: 8\n", + "Total number of inequality constraints...............: 0\n", + " inequality constraints with only lower bounds: 0\n", + " inequality constraints with lower and upper bounds: 0\n", + " inequality constraints with only upper bounds: 0\n", + "\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 0 0.0000000e+00 9.09e+07 1.00e+00 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0\n", + "Reallocating memory for MA57: lfact (247)\n", + " 1r 0.0000000e+00 9.09e+07 9.99e+02 2.5 0.00e+00 - 0.00e+00 7.73e-09R 9\n", + " 2r 0.0000000e+00 8.42e+07 8.24e+03 2.5 7.20e+02 - 1.64e-02 2.59e-02f 1\n", + " 3r 0.0000000e+00 8.37e+07 7.72e+03 1.8 8.82e+04 - 5.56e-04 3.77e-05f 1\n", + " 4r 0.0000000e+00 3.76e+07 2.65e+04 1.8 1.13e+03 0.0 1.27e-01 1.63e-01f 1\n", + " 5r 0.0000000e+00 3.60e+07 2.30e+04 1.8 6.83e+01 1.3 7.53e-02 1.45e-01f 1\n", + " 6r 0.0000000e+00 4.17e+07 1.77e+04 1.8 2.10e+02 0.9 7.11e-02 1.47e-01f 1\n", + " 7r 0.0000000e+00 4.08e+07 1.75e+04 1.8 3.95e+02 0.4 2.35e-01 8.19e-03f 1\n", + " 8r 0.0000000e+00 3.13e+07 1.75e+04 1.8 1.12e+03 -0.1 3.57e-01 3.16e-02f 1\n", + " 9r 0.0000000e+00 7.77e+06 1.74e+04 1.8 1.00e+04 - 1.43e-02 9.06e-03f 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 10r 0.0000000e+00 7.36e+06 1.70e+04 1.8 2.14e+02 - 2.20e-01 2.38e-02f 1\n", + " 11r 0.0000000e+00 5.93e+06 1.67e+04 1.8 1.72e+02 - 7.89e-01 1.95e-01f 1\n", + " 12r 0.0000000e+00 1.54e+06 3.54e+04 1.8 1.06e+02 - 8.80e-01 7.41e-01f 1\n", + " 13r 0.0000000e+00 1.21e+06 2.79e+04 1.8 4.60e+00 - 1.00e+00 2.12e-01h 1\n", + " 14r 0.0000000e+00 3.31e+03 4.79e+01 1.8 2.39e+00 - 1.00e+00 1.00e+00f 1\n", + " 15r 0.0000000e+00 2.85e+03 7.72e+02 -0.2 2.01e+00 - 9.81e-01 9.09e-01f 1\n", + " 16r 0.0000000e+00 2.47e+03 6.60e+02 -0.2 9.87e-01 - 1.00e+00 1.48e-01f 1\n", + " 17r 0.0000000e+00 3.18e-01 8.47e+01 -0.2 1.39e-01 - 1.00e+00 1.00e+00f 1\n", + " 18r 0.0000000e+00 3.18e-01 6.25e+01 -0.2 1.96e-01 - 1.00e+00 1.00e+00f 1\n", + " 19r 0.0000000e+00 3.18e-01 5.46e+00 -0.2 3.26e-02 - 1.00e+00 1.00e+00h 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 20r 0.0000000e+00 3.18e-01 1.44e+02 -1.6 2.34e-01 - 1.00e+00 1.00e+00f 1\n", + " 21r 0.0000000e+00 3.18e-01 1.45e+01 -1.6 9.26e-02 - 9.13e-01 1.00e+00f 1\n", + " 22r 0.0000000e+00 3.18e-01 1.46e+01 -1.6 1.71e-01 - 1.00e+00 1.25e-01f 4\n", + " 23r 0.0000000e+00 3.18e-01 1.44e+01 -1.6 1.24e-01 - 1.00e+00 1.56e-02h 7\n", + " 24r 0.0000000e+00 3.18e-01 1.41e+01 -1.6 1.27e-01 - 1.00e+00 1.56e-02h 7\n", + " 25r 0.0000000e+00 3.18e-01 1.39e+01 -1.6 1.24e-01 - 1.00e+00 1.56e-02h 7\n", + " 26r 0.0000000e+00 3.18e-01 1.37e+01 -1.6 1.22e-01 - 1.00e+00 1.56e-02h 7\n", + " 27r 0.0000000e+00 3.18e-01 1.35e+01 -1.6 1.20e-01 - 1.00e+00 1.56e-02h 7\n", + " 28r 0.0000000e+00 3.18e-01 1.33e+01 -1.6 1.18e-01 - 1.00e+00 1.56e-02h 7\n", + " 29r 0.0000000e+00 3.18e-01 1.31e+01 -1.6 1.17e-01 - 1.00e+00 1.56e-02h 7\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 30r 0.0000000e+00 3.18e-01 1.29e+01 -1.6 1.15e-01 - 1.00e+00 1.56e-02h 7\n", + " 31r 0.0000000e+00 3.18e-01 1.28e+01 -1.6 1.13e-01 - 1.00e+00 1.56e-02h 7\n", + " 32r 0.0000000e+00 3.18e-01 4.28e+01 -1.6 1.11e-01 - 1.00e+00 1.00e+00w 1\n", + " 33r 0.0000000e+00 3.18e-01 1.43e-03 -1.6 2.09e-05 - 1.00e+00 1.00e+00w 1\n", + " 34r 0.0000000e+00 3.18e-01 1.37e+01 -3.7 6.94e-02 - 1.00e+00 1.00e+00f 1\n", + " 35r 0.0000000e+00 3.17e-01 3.73e+04 -3.7 3.39e+00 - 1.44e-01 1.00e+00f 1\n", + " 36r 0.0000000e+00 3.17e-01 6.78e+03 -3.7 5.99e-01 - 1.00e+00 1.00e+00f 1\n", + " 37r 0.0000000e+00 3.17e-01 7.66e+00 -3.7 4.92e-03 - 1.00e+00 1.00e+00h 1\n", + " 38r 0.0000000e+00 3.17e-01 1.65e-04 -3.7 9.43e-05 - 1.00e+00 1.00e+00h 1\n", + " 39r 0.0000000e+00 3.17e-01 1.30e+00 -5.6 9.94e-04 - 1.00e+00 1.00e+00f 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 40r 0.0000000e+00 3.11e-01 6.82e+04 -5.6 3.21e+01 - 1.41e-01 1.00e+00f 1\n", + " 41r 0.0000000e+00 3.11e-01 1.17e+01 -5.6 4.97e-03 - 1.00e+00 1.00e+00h 1\n", + "\n", + "Number of Iterations....: 41\n", + "\n", + " (scaled) (unscaled)\n", + "Objective...............: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Dual infeasibility......: 2.2783833299154238e-01 2.2783833299154238e-01\n", + "Constraint violation....: 3.1132475345243688e-01 3.1132475345243688e-01\n", + "Complementarity.........: 2.7808801399127131e-06 2.7808801399127131e-06\n", + "Overall NLP error.......: 3.1132475345243688e-01 3.1132475345243688e-01\n", + "\n", + "\n", + "Number of objective function evaluations = 109\n", + "Number of objective gradient evaluations = 3\n", + "Number of equality constraint evaluations = 109\n", + "Number of inequality constraint evaluations = 0\n", + "Number of equality constraint Jacobian evaluations = 44\n", + "Number of inequality constraint Jacobian evaluations = 0\n", + "Number of Lagrangian Hessian evaluations = 42\n", + "Total CPU secs in IPOPT (w/o function evaluations) = 0.008\n", + "Total CPU secs in NLP function evaluations = 0.000\n", + "\n", + "EXIT: Converged to a point of local infeasibility. Problem may be infeasible.\n" + ] + } + ], + "source": [ + "perturb_model(m)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As can be seen from the solver logs, IPOPT was unable to find a feasible solution to this problem, and went into restoration from the first iteration. However, there is no reason the perturbed conditions should not be feasible (you can verify this with the `infeasibility_explainer` in the Diagnostics Toolbox if you desire).\n", + "\n", + "There are a few reasons for this, most of which can be resolved by providing better scaling for the model. One of the reasons is because we have a number of concentrations approaching zero which results in a number of very small numbers appearing in the problem.\n", + "\n", + "A bigger issue however is the fact that in our initial model we are feeding reactants in stoichiometric amounts (1:1) meaning that both reactant concentrations go to zero at equilibrium. This results in the Jacobian for the reaction rate constraint becoming singular; with `rate = K_rxn * [NaOH] * [EthylAcetate]` if both concentrations go to zero then the partial derivative of the reaction rate with respect to each concentration is also 0, and thus our solver has no idea of what direction to move when trying to converge the problem. Whilst scaling can help work around this, this is ultimately an indication that our problem is not well formulated. In practice, an Equilibrium reactor model is not well suited for systems involving irreversible rate-based reactions as it requires concentrations to be driven to zero, and is an especially poor choice for stoichiometric feeds." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2: Understanding the Model\n", + "\n", + "Now that we have a test case (or multiple test cases), we can start planning out the new scaling routine. As our goal is to estimate scaling factors for as many of the variables and constraints in the model as possible, the first step is to understand what variables and constraints may be present in the model. Note that we need to be careful to check for all variables and constraints that may exist under different configuration options, and not just those that appear in the our test case(s).\n", + "\n", + "Given the modular nature of IDAES, we need to also make a distinction between those variables and constraints we have direct knowledge of, and those that are created via modular sub-models that we do not know the details of. The most common examples of modular sub-models are the ``StateBlocks`` and ``ReactionBlocks`` created by the associated property packages; we know that these exist and we create these in our models, but we do not know what variables and constraints they may construct. On the other hand, we also have variables and constraints that we construct directly in our model. For the purposes of this we include those variables and constraints constructed by ``ControlVolumes`` as being directly construed; whilst the ``ControlVolume`` might automate the details for us, we directly call methods on the ``ControlVolume`` to create these variables and constraints and we know what they will be based on the instructions we give.\n", + "\n", + "For our example of the ``EquilibriumReactor``, let us take a look at the code in the ``build`` method, which has been copied below for convenience:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "def build(self):\n", + " \"\"\"\n", + " Begin building model.\n", + "\n", + " Args:\n", + " None\n", + "\n", + " Returns:\n", + " None\n", + " \"\"\"\n", + " # Call UnitModel.build to setup dynamics\n", + " super(EquilibriumReactorData, self).build()\n", + "\n", + " # Build Control Volume\n", + " self.control_volume = ControlVolume0DBlock(\n", + " dynamic=self.config.dynamic, # Config block forces this to be False\n", + " has_holdup=self.config.has_holdup, # Config block forces this to be False\n", + " property_package=self.config.property_package,\n", + " property_package_args=self.config.property_package_args,\n", + " reaction_package=self.config.reaction_package,\n", + " reaction_package_args=self.config.reaction_package_args,\n", + " )\n", + "\n", + " # No need for control volume geometry\n", + "\n", + " self.control_volume.add_state_blocks(\n", + " has_phase_equilibrium=self.config.has_phase_equilibrium\n", + " )\n", + "\n", + " self.control_volume.add_reaction_blocks(\n", + " has_equilibrium=self.config.has_equilibrium_reactions\n", + " )\n", + "\n", + " self.control_volume.add_material_balances(\n", + " balance_type=self.config.material_balance_type,\n", + " has_rate_reactions=self.config.has_rate_reactions,\n", + " has_equilibrium_reactions=self.config.has_equilibrium_reactions,\n", + " has_phase_equilibrium=self.config.has_phase_equilibrium,\n", + " )\n", + "\n", + " self.control_volume.add_energy_balances(\n", + " balance_type=self.config.energy_balance_type,\n", + " has_heat_of_reaction=self.config.has_heat_of_reaction,\n", + " has_heat_transfer=self.config.has_heat_transfer,\n", + " )\n", + "\n", + " self.control_volume.add_momentum_balances(\n", + " balance_type=self.config.momentum_balance_type,\n", + " has_pressure_change=self.config.has_pressure_change,\n", + " )\n", + "\n", + " # Add Ports\n", + " self.add_inlet_port()\n", + " self.add_outlet_port()\n", + "\n", + " if self.config.has_rate_reactions:\n", + " # Add equilibrium reactor performance equation\n", + " @self.Constraint(\n", + " self.flowsheet().time,\n", + " self.config.reaction_package.rate_reaction_idx,\n", + " doc=\"Rate reaction equilibrium constraint\",\n", + " )\n", + " def rate_reaction_constraint(b, t, r):\n", + " # Set kinetic reaction rates to zero\n", + " return b.control_volume.reactions[t].reaction_rate[r] == 0\n", + "\n", + " # Set references to balance terms at unit level\n", + " if (\n", + " self.config.has_heat_transfer is True\n", + " and self.config.energy_balance_type != EnergyBalanceType.none\n", + " ):\n", + " self.heat_duty = Reference(self.control_volume.heat[:])\n", + "\n", + " if (\n", + " self.config.has_pressure_change is True\n", + " and self.config.momentum_balance_type != MomentumBalanceType.none\n", + " ):\n", + " self.deltaP = Reference(self.control_volume.deltaP[:])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If we look through the code in the ``build`` method, we can see that the model contains a single 0D Control Volume with ``StateBlocks``, a ``ReactionBlock``, material, energy and momentum balances and one additional constraint (``rate_reaction_constraint``). Thus, we have the following components that need to be scaled:\n", + "\n", + "3 Sub-Models:\n", + "\n", + "1. The inlet state sub-model (``model.control_volume.properties_in``)\n", + "2. The outlet state sub-model (``model.control_volume.properties_out``)\n", + "3. The reaction sub-model (``model.control_volume.reactions``)\n", + "\n", + "Unit Model Variables (from control volume options):\n", + "\n", + "1. Rate-based reaction extent and generation terms\n", + "2. Equilibrium-based reaction extent and generation terms\n", + "3. Inherent reaction extent and generation terms (no explicit argument, but determined by properties)\n", + "4. Phase equilibrium generation terms\n", + "5. Energy balance heat term\n", + "6. Energy balance heats of reaction\n", + "7. Pressure drop\n", + "\n", + "Unit Model Constraints (from control volume + 1 in the ``build`` method):\n", + "\n", + "1. Material balance constraints\n", + "2. Reaction stoichiometry constraints\n", + "3. Energy balance constraints\n", + "4. Pressure balance constraints\n", + "5. ``rate_reaction_constraint``\n", + "\n", + "When writing our ``Scaler`` we will need to consider all of these to determine how best to estimate scaling factors. Before starting however, we should check the numerical diagnostics for each case study, both to see what scaling issues currently exist and to establish a baseline for comparison once we have a proposed ``Scaler`` for our model.\n", + "\n", + "The cell below calls the ``report_numerical_issues`` method for the unscaled test case." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 1.540E+12\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 1 Constraint with large residuals (>1.0E-05)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "7 Cautions\n", + "\n", + " Caution: 1 Variable with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme value (<1.0E-04 or >1.0E+04)\n", + " Caution: 1 Constraint with mismatched terms\n", + " Caution: 3 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_constraints_with_large_residuals()\n", + " compute_infeasibility_explanation()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "dt.report_numerical_issues()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Looking at the results of the diagnostics, we can see that the test case is not particularly well scaled. The Jacobian condition number is rather large (1e12), and the diagnostics are reporting a number of variables with extremely large or small values, and 3 variables and 2 constraints with poorly scaled Jacobians. As we develop our new ``Scaler`` for the ``EquilibriumReactor`` we will hopefully see these improve.\n", + "\n", + "We can also use the Diagnostics Toolbox to further explore these issues to get a better idea of which variables and constraints might be causing issues. For example, lets display the set of variables and constraints with extreme Jacobian norms." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "The following variable(s) are associated with extreme Jacobian values (<1.0E-04 or>1.0E+04):\n", + "\n", + " fs.equil.control_volume.properties_out[0.0].flow_vol: 9.427E+07\n", + " fs.equil.control_volume.properties_out[0.0].temperature: 4.172E+06\n", + " fs.equil.control_volume.rate_reaction_extent[0.0,R1]: 4.900E+04\n", + "\n", + "====================================================================================\n", + "====================================================================================\n", + "The following constraint(s) are associated with extreme Jacobian values (<1.0E-04 or>1.0E+04):\n", + "\n", + " fs.equil.control_volume.enthalpy_balances[0.0]: 9.436E+07\n", + " fs.equil.control_volume.material_balances[0.0,Liq,H2O]: 5.539E+04\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "dt.display_variables_with_extreme_jacobians()\n", + "dt.display_constraints_with_extreme_jacobians()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "These diagnostics can help give us an idea of what may be causing problems in our model. From the output above, we can see that the variables with large Jacobian norms (i.e., high sensitivities) are the outlet flow rate and temperature, as well as the rate-based extent of reaction. We can also see that the constraints with large Jacobian norms are the enthalpy balance and H20 material balance for the reactor. However, caution must be used when interpreting these in isolation, as understanding what these mean is often complicated and initial impressions may be misleading. To get a better picture of what is contributing to extreme Jacobian values you should make use of the tools in the diagnostics ``SVDToolbox``, however that is a topic for another example.\n", + "\n", + "For example, one might wonder why the volumetric flow rate at the outlet of the reactor is so important as it is effectively determined by the inlet flow rate (due to the water balance effectively conserving volume). However, it is important to remember that the Jacobian does not consider the value of the variable, but rather its partial derivatives. Thus, it is important to compare the list of variables and constraints with large Jacobian norms and think about how those intersect.\n", + "\n", + "Let's start by taking a look at the H2O material balance. The cell below prints the constraint expression in a compact form that only shows top level ``Expressions`` rather than expanding these to show the full expression tree." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fs.equil.control_volume.properties_in[0.0].flow_vol*fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] - fs.equil.control_volume.properties_out[0.0].flow_vol*fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] + fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] == 0" + ] + } + ], + "source": [ + "from idaes.core.util.misc import print_compact_form\n", + "\n", + "print_compact_form(m.fs.equil.control_volume.material_balances[0, \"Liq\", \"H2O\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Looking at how the outlet volumetric flowrate appears in the H2O balance equation above, it can be seen that the volumetric flow term is multiplied by the molar concentration of water, $F \\times C_{H2O}$. Whilst $C_{H2O}$ is assumed to be constant in this model (and equal to the molar density of pure water at ambient conditions), this means that the partial derivative of the constraint term with respect to flow is $\\frac{\\partial F C_{H2O}}{\\partial F} = C_{H2O}$; given that $C_{H2O}$ is equal to 5.5E4 mol/liter, you can quickly see why it is being identified as an issue.\n", + "\n", + "If we look at the energy balance, we will find that it is similar." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fs.properties.dens_mol*fs.properties.cp_mol*fs.equil.control_volume.properties_in[0.0].flow_vol*(fs.equil.control_volume.properties_in[0.0].temperature - fs.properties.temperature_ref) - fs.properties.dens_mol*fs.properties.cp_mol*fs.equil.control_volume.properties_out[0.0].flow_vol*(fs.equil.control_volume.properties_out[0.0].temperature - fs.properties.temperature_ref) + fs.equil.control_volume.heat[0.0] + fs.equil.control_volume.heat_of_reaction[0.0] == 0" + ] + } + ], + "source": [ + "print_compact_form(m.fs.equil.control_volume.enthalpy_balances[0.0])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Whilst a bit harder to read due to the size of the constraint, you can see that it involves the term $\\rho \\times c_p \\times F \\times (T - T_{ref})$, where $c_p$ is the specific molar heat capacity of the solution, $T$ is temperature and $T_{ref}$ is the reference temperature. Given that $\\rho$ is of order 1E4 (a) and $c_p \\times (T-T_{ref})$ is of order 1E3, this means that the partial derivative with respect to the volumetric flowrate is even larger than that for the H2O balance. This also explains the appearance of the outlet temperature as well, as we can see that it is multiplied by a number of large values as well and thus has a large partial derivative.\n", + "\n", + "It is also important to mention that having a large value in the Jacobian does not mean a variable is \"important\" (and conversely a small value is not unimportant). What is important is how sensitive the constraint residual is to that change in variable, which is often difficult to assess from the Jacobian alone (which is where the ``SVDToolbox`` can assist).\n", + "\n", + "\n", + "## Step 3: Creating a New Scaler Class\n", + "\n", + "To create a new scaling routine for the equilibrium reactor, we start by creating a new ``Scaler`` class which inherits from the ``CustomScalerBase`` class in ``idaes.core.scaling``. The ``CustomScalerBase`` class contains a number of useful methods to help us in developing our scaling routine, including some placeholder methods for implementing a standard scaling workflow and helper methods for doing common tasks.\n", + "\n", + "The cell below shows how to create our new class which we will name ``EquilibriumReactorScaler`` as well as two key methods we will fill out as part of this workshop." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.scaling import CustomScalerBase\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ``variable_scaling_routine`` and ``constraint_scaling_routine`` methods are used to implement subroutines for scaling the variables and constraints in the model respectively. Separately, there is a ``scale_model`` method that will call each of these in sequence in order to scale an entire model by applying the following steps:\n", + "\n", + "1. apply variable scaling routine,\n", + "2. apply first stage scaling fill-in,\n", + "3. apply constraint scaling routine,\n", + "4. apply second stage scaling fill-in.\n", + "\n", + "The second and fourth steps are intended to allow users to provide methods to fill in missing scaling information that was not provided by the first and second steps, or to provide a way to update the scaling factors with more information.\n", + "\n", + "Both the ``variable_scaling_routine`` and ``constraint_scaling_routine`` are user-facing methods and take three arguments.\n", + "\n", + "1. The model to be scaled.\n", + "2. An argument indicating whether to overwrite any existing scaling factors. Generally we assume that any existing scaling factors were provided by the user for a reason, so by default we set this to ``False``. However, there will likely be cases where a user wants to overwrite their existing scaling factors so this argument exists to let us pass on those instructions.\n", + "3. A mapping of user-provided ``Scalers`` to use when scaling submodels.\n", + "\n", + "## Step 4: Apply Scaling to Sub-Models\n", + "\n", + "First, lets look at how to scale the property and reaction sub-models. As these are modular packages, we do not know what variables and constraints may be in them, so we cannot (and should not) scale any of these directly. However, we can (hopefully) assume that there are ``Scalers`` available for these sub-models, either through default ``Scalers`` associated with the property packages or provided by the user. Thus, what we want to do here is to call the variable and constraint scaling routines from the ``Scaler`` associated with each sub-model, which we can do using the ``call_submodel_scaler_method`` method from the ``CustomScalerBase`` class.\n", + "\n", + "The cell below prints the doc-string for the ``call_submodel_scaler_method`` method so we can see what the expected arguments are." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Help on function call_submodel_scaler_method in module idaes.core.scaling.custom_scaler_base:\n", + "\n", + "call_submodel_scaler_method(self, submodel, method: str, submodel_scalers: pyomo.common.collections.component_map.ComponentMap = None, overwrite: bool = False)\n", + " Call scaling method for submodel.\n", + " \n", + " Scaler for submodel is taken from submodel_scalers if present, otherwise the\n", + " default scaler for the submodel is used.\n", + " \n", + " Args:\n", + " submodel: submodel to be scaled\n", + " submodel_scalers: user provided ComponentMap of Scalers to use for submodels\n", + " method: name of method to call from submodel (as string)\n", + " overwrite: whether to overwrite existing scaling factors\n", + " \n", + " Returns:\n", + " None\n", + "\n" + ] + } + ], + "source": [ + "help(CustomScalerBase.call_submodel_scaler_method)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that ``call_submodel_scaler_method`` takes 4 arguments:\n", + "\n", + "1. ``submodel`` is the submodel we want to scale. \n", + "2. The ``submodel_scalers`` argument should be passed through from the ``variable_scaling_routine`` or ``constraint_scaling_routine`` method.\n", + "3. The name of the method we want to call from the ``Scaler`` when we get it - this will normally be either ``variable_scaling_routine`` (if we are scaling variables) or ``constraint_scaling_routine`` (if we are doing constraints).\n", + "4. The ``overwrite`` argument should also be passed through from the ``variable_scaling_routine`` or ``constraint_scaling_routine`` method.\n", + "\n", + "For the Equilibrium Reactor, we have three submodels to scale; inlet state, outlet state and reactions. As mentioned in the introduction, when developing scaling routines always start with the things you have the most information about. In this case, we likely know the most about the inlet state; either it is a defined feed state (like in our test case) or we have some idea of the state (and scaling) from propagating values from an upstream operation. So, to apply variable scaling to the inlet state we would do the following:\n", + "\n", + "```python\n", + "self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + ")\n", + "```\n", + "\n", + "Once we have an idea of scaling for the inlet we can use that information to try to estimate scaling for the outlet state. The default assumption is that the scaling of the outlet will be similar to that of the inlet, so the easy path is to copy scaling from the inlet state to the outlet. However, we know that something must change between inlet and outlet (as otherwise this unit operation is doing nothing) so we should always stop and think about whether we can try to estimate these changes. For example, in a pressure changer we know, or be able to estimate, the pressure change across the unit and thus be able to change the scaling of pressure between the inlet and outlet. However, keep in mind that over-scaling can make things worse so be judicious when deciding whether to adjust scaling based on estimates.\n", + "\n", + "In regards to this, Equilibrium Reactors are one of the more challenging units to scale, as it is very hard to know what the outlet flows and concentrations will be without knowing what the reactions are (and even if you know the reactions it is often hard to know the equilibrium state). In most cases, we have no reliable way to estimate the outlet flowrate and concentrations, so this is best left to the user to provide. In the case of temperature and pressure, whilst we may expect these to change but any change will generally be 1-2 orders of magnitude less than the inlet state and thus the overall scale of these will likely remain similar. Thus, for the Equilibrium Reactor it is probably sufficient to just scale the outlet state based on the inlet state.\n", + "\n", + "The ``CustomScalerBase`` class has a method for propagating scaling factors for state variables from one state to another called ``propagate_state_scaling`` as see below." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Help on function propagate_state_scaling in module idaes.core.scaling.custom_scaler_base:\n", + "\n", + "propagate_state_scaling(self, target_state, source_state, overwrite: bool = False)\n", + " Propagate scaling of state variables from one StateBlock to another.\n", + " \n", + " Indexing of target and source StateBlocks must match.\n", + " \n", + " Args:\n", + " target_state: StateBlock to set scaling factors on\n", + " source_state: StateBlock to use as source for scaling factors\n", + " overwrite: whether to overwrite existing scaling factors\n", + " \n", + " Returns:\n", + " None\n", + "\n" + ] + } + ], + "source": [ + "help(CustomScalerBase.propagate_state_scaling)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here we can see that ``propagate_state_scaling`` takes three arguments; the ``StateBlock`` we want to apply scaling to, the ``StateBlock`` we want to use as the source for the scaling factors, and the ``overwrite`` argument. Thus, we can propagate scaling from the inlet state to the outlet state as shown below.\n", + "\n", + "```python\n", + "self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + ")\n", + "```\n", + "\n", + "This only propagates scaling factors for the state variables, however, so we should then call the ``Scaler`` for the outlet state block to scale any remaining variables and constraints (which will hopefully make use of the scaling factors for the state variables we just propagated).\n", + "\n", + "We can then move on to scaling the ``ReactionBlock``. ``ReactionBlocks`` are slightly unusual in that they rely heavily on the state variables defined in a separate ``StateBlock`` - in this case the outlet state block. As we just applied a ``Scaler`` to the outlet state block, we can assume that all of the necessary variables have been scaled so all we need to do now is call a ``Scaler`` for the ``ReactionBlock``.\n", + "\n", + "All of this is shown in the cell below." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can then take a similar approach for the constraint scaling routine as shown below. Note that there is no need for a propagation step here as the residual of a constraint is derived from the value of the variables (which we handled in the variable scaling step)." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets do a quick check to see if our new scaler works and how it has affected the model scaling. The cell below creates a function that builds a new instance of the model (to avoid contamination from previous model runs then creates an instance of our new scaler and applies it to the model. We then solve the scaled model (adding scaling changes constraint residuals so we want to solve to the scaled state). Finally, the function prints a report of the scaling factors in the model and calls the ``report_numerical_issues`` method from the Diagnostics Toolbox." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import check_optimal_termination, TransformationFactory\n", + "\n", + "from idaes.core.scaling import report_scaling_factors\n", + "\n", + "\n", + "def check_scaling(tee=False):\n", + " # Build new instance of model\n", + " m = build_model()\n", + "\n", + " # Apply scaler to model\n", + " scaler = EquilibriumReactorScaler()\n", + " scaler.scale_model(m.fs.equil)\n", + "\n", + " # Solve scaled model\n", + " results = solver.solve(m, tee=tee)\n", + " if check_optimal_termination(results):\n", + " print(\"\\nModel Solved\\n\")\n", + " else:\n", + " print(\"\\nModel Failed to Converge!\\n\")\n", + "\n", + " # Print report of scaling factors\n", + " report_scaling_factors(m.fs.equil, descend_into=True)\n", + "\n", + " # Show numerical issues report\n", + " sm = TransformationFactory(\"core.scale_model\").create_using(m, rename=False)\n", + "\n", + " dt = DiagnosticsToolbox(model=sm.fs.equil)\n", + " dt.report_numerical_issues()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets run the ``check_scaling`` function and see how the model scaling has changed." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.enthalpy_balances[0.0] None\n", + "fs.equil.control_volume.pressure_balance[0.0] None\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.022E+09\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "5 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "From the scaling factor report, we can see that by calling the submodel scalers we have already scaled many of the variables in our problem, as well as three of the constraints. If we look at the \"Scaled Value\" column for the variables, we can also see that most of the scaled values are close to 1 (the few outliers might be things we want to look into more later on).\n", + "\n", + "From the numerical diagnostics, we can see that the Jacobian condition number has decreased by a few orders of magnitude, although it is still large, whilst we still have a number of potential issues with individual variables and constraints. All up though, this appears to be a step in the right direction." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5: Apply Variable Scaling\n", + "\n", + "Next, we need to look at scaling the variables and constraints that make up the unit model itself. From a conceptual standpoint, it is generally easiest to start with the variables as we generally have at least some idea of the magnitude of these.\n", + "\n", + "For the equilibrium reactor, we have the following variables we need to scale:\n", + "\n", + "1. Rate-based reaction extent and generation terms\n", + "2. Equilibrium-based reaction extent and generation terms\n", + "3. Inherent reaction extent and generation terms\n", + "4. Phase equilibrium generation terms\n", + "5. Energy balance heat term\n", + "6. Energy balance heats of reaction\n", + "7. Pressure drop\n", + "\n", + "Many of these are hard to know a priori - anything related to a reaction is very hard to know without knowing the reaction behavior. Considering that the equilibrium reactor is modular, we have little to no way of knowing these in the general case (and even in the specific test case it is hard enough). We can assume that the reaction package will scale all of its variables (i.e., rate and equilibrium constants, and reaction rates), however it is hard to project these to unit model scaling.\n", + "\n", + "For a CSTR we can say that ``extent = volume*rate`` and thus estimate scaling, but this does not work for equilibrium systems where 1) volume is undefined, 2) reaction rate at the outlet state is being driven to zero to satisfy equilibrium, and 3) extent is solved implicitly to satisfy the need for reaction rate to equal zero.\n", + "\n", + "Considering that a bad guess is often worse than no guess, we will not scale these right now - it is important to remember that our goal is to improve the overall scaling so if we do not know how to scale something it is generally best to leave it unscaled. We might come back to these later if necessary, but for now we will leave these either for the user to provide based on knowledge of their system, or for automated fill-in using some autoscaler.\n", + "\n", + "For the heat and deltaP terms, these are dependent on extensive variables in each case study and we have no way of knowing their exact values. However, we can probably take a good guess at order-of-magnitude using engineering knowledge; heat duties are generally approximately one order of magnitude smaller than the enthalpy flows,\n", + "and pressure drops are generally on the order of 0.1 bar.\n", + "\n", + "To apply scaling for the pressure drop term, we can make use of the ``scale_variable_by_units`` method in ``CustomScalerBase``. This method looks up the units of measurement for the variable, and then loops in the class attribute ``UNIT_SCALING_FACTORS`` dictionary to find an equivalent unit for the quantity of interest and an associated scaling factor. If a scaling factor is found, it is converted as necessary; e.g., in this case pressure is defined in ``Pa`` but we can set the default scaling factor in ``bar`` and it will be converted as appropriate. The code required to do this is below.\n", + "\n", + "```python\n", + "UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + "}\n", + "\n", + "def variable_scaling_routine(*args, **kwargs):\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t],\n", + " overwrite=overwrite\n", + " )\n", + "```\n", + "\n", + "There are a few things to note here:\n", + "\n", + "1. As we expect the pressure drop to be on the order of 0.1 bar, we need to set a scaling factor of 10 for quantities with units of pressure. Also note that the key ``\"Pressure Change\"`` is for documentation purposes only and is not actually used by the code (but must be there). \n", + "\n", + "
\n", + "NOTE We cannot distinguish between different quantities with the same apparent units (e.g., we cannot distinguish between an absolute pressure and a pressure change).\n", + "
\n", + "\n", + "2. Note that scaling is applied to elements of indexed components and not to the indexed component as a whole, and thus we need to use a ``for`` loop to iterate over the time index. This is done to force modelers to consider how the scaling of a variable or constraint will vary over the indexed domain, and try to discourage automatically setting a single scaling factor for all points.\n", + "3. Pressure change is a configuration argument in our unit model, and thus may not be present in all cases. Therefore, we need the ``hasattr`` check to see if we need to scale ``deltaP`` or not.\n", + "\n", + "For the case of the heat duty, we want to scale based on the incoming enthalpy flow which means we first need to get the expected magnitude of the enthalpy flow. For that, we can use the ``get_expression_nominal_values`` method in ``CustomScalerBase`` which uses an expression walker to go through an expression to return a list of the expected magnitude (or nominal value) of all additive terms in the expression based on the scaling factors for the variables involved.\n", + "\n", + "We can get an expression for the enthalpy flow term using the ``get_enthalpy_flow_terms`` method from the associated ``StateBlock``. We should assume this expression might contain multiple terms, so we should sum all the values returned to get the overall magnitude of the enthalpy flow term. Once we have this, we can then get the scaling factor for the heat duty by ``sf = abs(1/(0.1*enthalpy_flow))`` - note that the tools insist on scaling factors being positive (for sanity) and thus we need the absolute value here in case enthalpy flow is negative (which is not uncommon for enthalpy). The code to do this is shown below.\n", + "\n", + "```python\n", + "if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[t].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is general one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(model.control_volume.heat[t], abs(1 / (0.1 * h_in)))\n", + "```\n", + "\n", + "Putting all of this together results in the code below for our ``EquilibriumReactorScaler`` class." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import units\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " # =======================================================================================\n", + " # New Code\n", + " UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + " }\n", + " # =======================================================================================\n", + "\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # =======================================================================================\n", + " # New Code\n", + "\n", + " # Pressure drop - optional\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t], overwrite=overwrite\n", + " )\n", + "\n", + " # Heat transfer - optional\n", + " # Scale heat based on enthalpy flow entering reactor\n", + " if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[\n", + " t\n", + " ].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is generally one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(\n", + " model.control_volume.heat[t], abs(1 / (0.1 * h_in))\n", + " )\n", + " # =======================================================================================\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once again, lets run the ``check_scaling`` function and see how we are going." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] 4.794E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] 1.000E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.enthalpy_balances[0.0] None\n", + "fs.equil.control_volume.pressure_balance[0.0] None\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.022E+09\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "5 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Our updates have resulted in scaling factors for ``heat`` and ``deltaP`` appearing in the scaling report which is good, but comparing the diagnostics from the previous step we can see that the Jacobian condition number has not changed. Does this mean we did something wrong?\n", + "\n", + "The answer is no - when we add a scaling factor to a variable, wherever that variable appears in a constraint it is replaced with ``sf*v_scaled``. Given that ``v_scaled = v/sf``, this means that for variables which only appear linearly in constraints then the partial derivative with respect to the scaled variable does not change either; thus the Jacobian is unaffected by scaling only the linear variables. In the case of this example, it turns out that almost all the variables appear linearly and thus we see no change in the Jacobian condition number.\n", + "\n", + "
\n", + "NOTE It is important to note that partial scaling of a model (e.g., variables only) can often appear worse than that of the unscaled model. Generally, it is best to wait until you have scaled both variables and constraints to make a decision on whether your attempts at scaling have made the problem better or worse, and you should not be discouraged if things look worse while in an intermediate state.\n", + "
\n", + "\n", + "\n", + "## Step 6: Apply Constraint Scaling\n", + "\n", + "Now that we have scaled all the variables that we can (for now at least), we can move on to scaling constraints. The advantage of scaling all the variables first means that now we have an idea of the expected magnitude for all terms in the constraints which we can use to estimate scaling factors. For the Equilibrium reactor model, we need to scale all the constraints in the control volume, as well as the unit level constraint equating all reaction rates to zero.\n", + "\n", + "There are many approaches to estimating scaling for constraints, and different approaches are better suited to certain situations. ``CustomScalerBase`` contains a ``scale_constraint_by_nominal_value`` method which can be used to automatically implement a number of common approaches to save you the effort of having to manually implement these yourself. As of writing, the approaches (or schemes) supported are:\n", + "\n", + "1. ``ConstraintScalingScheme.inverseMaximum`` - scale the constraint based on the term with the largest absolute expected magnitude. This is scheme is useful for cases where most terms have similar magnitudes and is a good initial point to start.\n", + "2. ``ConstraintScalingScheme.inverseMinimum`` - scale the constraint based on the term with the smallest absolute expected magnitude. This scheme is similar to the inverse maximum scheme and is useful for cases where you have a constraint with a number of smaller terms mixed with a few larger terms, or cases where the smaller term is expected to be most significant. This scheme should be used carefully however as it can result in large scaling factors making convergence of larger terms difficult.\n", + "3. ``ConstraintScalingScheme.harmonicMean`` - scale the constraint using the harmonic mean of the absolute expected magnitude of all terms (``sf = sum(1/abs(nominal value))``). This scheme is most useful when you have a constraint with terms with a mix of expected magnitudes where you need to find a balance between the large and small terms.\n", + "4. ``ConstraintScalingScheme.inverseSum`` - scale the constraint using the sum of the absolute expected magnitudes of all terms. Situationally useful for cases with terms of mixed magnitudes.\n", + "5. ``ConstraintScalingScheme.inverseRSS`` - scale the constraint using the root sum of squares of the absolute expected magnitudes of all terms. Situationally useful for cases with terms of mixed magnitudes.\n", + "\n", + "``CustomScalerBase`` also contains a ``scale_constraint_by_nominal_derivative_norm`` method that can scale a constraint based on an estimate of the Jacobian norm associated with that constraint which can be useful for cases where you want to focus on the Jacobian scaling.\n", + "\n", + "
\n", + "NOTE The solver you intend to use may impact which approach provides the best scaling for a given model. For example, IPOPT has very good internal Jacobian scaling (when using the `gradient-based` scaling option), and thus benefits the most from focusing on scaling the constraint residual magnitudes as opposed to the Jacobian.\n", + "
\n", + "\n", + "For this workshop, we will start by just using ``ConstraintScalingScheme.inverseMaximum`` to get a starting point and to see if further scaling is required. We can apply this scheme to scale all the constraints in the control volume using the code below.\n", + "\n", + "```python\n", + "for c in model.control_volume.component_data_objects(\n", + " Constraint, descend_into=False\n", + "):\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + "```\n", + "\n", + "Adding this and a similar approach to scale the unit level constraint gives us the code below for our ``EquilibriumreactorScaler`` class." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.scaling import ConstraintScalingScheme\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + " }\n", + "\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # Pressure drop - optional\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t], overwrite=overwrite\n", + " )\n", + "\n", + " # Heat transfer - optional\n", + " # Scale heat based on enthalpy flow entering reactor\n", + " if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[\n", + " t\n", + " ].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is generally one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(\n", + " model.control_volume.heat[t], abs(1 / (0.1 * h_in))\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # =======================================================================================\n", + " # New Code\n", + " # Scale control volume constraints\n", + " for c in model.control_volume.component_data_objects(\n", + " Constraint, descend_into=False\n", + " ):\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # Scale unit level constraints\n", + " if hasattr(model, \"rate_reaction_constraint\"):\n", + " for c in model.rate_reaction_constraint.values():\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + " # =======================================================================================" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once again, let us use the ``check_scaling`` function to see how our ``Scaler`` performs." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] 4.794E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] 1.000E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] 1.000E+02\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] 1.000E+00\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] 1.000E+01\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] 1.000E-02\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] 1.000E+00\n", + "fs.equil.control_volume.enthalpy_balances[0.0] 7.715E-08\n", + "fs.equil.control_volume.pressure_balance[0.0] 9.869E-06\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 7.182E+04\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "3 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 2 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "From the results of ``check_scaling`` we can see that we now have scaling factors for almost all the variables and constraints in the model (the only exceptions being the reaction related variables we left unscaled earlier). More importantly, we can see that the Jacobian condition number is now down to ``7.2E4`` from the original ``1.5E12`` which is an impressive improvement (and for not a lot of effort on our part). We can also see that the numerical diagnostics are no longer reporting any variables or constraints with extreme Jacobians (there are 2 individual entries that are a bit large, but it appears they are not having a big impact on the condition number).\n", + "\n", + "We do see that there are a number of variables with values close to ``0`` which we should be wary of, but in this case it is due to the case study we are using. Here we are using an equilibrium reactor to drive a rate-based reaction to completion, which necessitates that at least one reactant have a concentration of zero as well as the reaction rate for all reactions. Thus, for this case these are unavoidable. As mentioned earlier, we really should be asking whether an Equilibrium Reactor is well suited for the reaction model we have here, and a Stoichiometric Reactor would probably have been a better choice (or a better reaction package which use reversible reactions with equilibrium).\n", + "\n", + "\n", + "## Step 7: Review Scaling Routine\n", + "\n", + "We now have a new ``Scaler`` for an equilibrium reactor that uses the modular nature of IDAES to implement a general purpose scaling routine (or so we hope at least). So, does this mean we are done?\n", + "\n", + "No, or not yet at least.\n", + "\n", + "We should always take a step back and ask ourselves if what we have is good enough and see if we can see any areas where we might be able to do better, or places where edge cases might exist. As a starting point, let us first see how we compare to an autoscaling routine using the model Jacobian. We can use the ``AutoScaler.scale_model`` method for this as shown below." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: model contains export suffix 'scaling_factor' that contains 10\n", + "component keys that are not exported as part of the NL file. Skipping.\n", + "\n", + "Model Solved\n", + "\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.863E+06\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "4 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 2 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 7 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "from idaes.core.scaling import AutoScaler\n", + "\n", + "m = build_model()\n", + "\n", + "scaler = EquilibriumReactorScaler()\n", + "autoscaler = AutoScaler()\n", + "\n", + "autoscaler.scale_model(m)\n", + "\n", + "solver = get_solver(\n", + " \"ipopt_v2\", writer_config={\"scale_model\": True, \"linear_presolve\": True}\n", + ")\n", + "results = solver.solve(m)\n", + "\n", + "if check_optimal_termination(results):\n", + " print(\"\\nModel Solved\\n\")\n", + "else:\n", + " print(\"\\nModel Failed to Converge!\\n\")\n", + "\n", + "sm = TransformationFactory(\"core.scale_model\").create_using(m, rename=False)\n", + "\n", + "dt = DiagnosticsToolbox(model=sm.fs.equil)\n", + "dt.report_numerical_issues()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that our ``EquilibriumReactorScaling`` routine actually results in a lower Jacobian condition number than the ``AutoScaler`` approach, so that is a sign we are doing things right. It is not unusual to see that we can get better scaling with a manual, magnitude based approach than an autoscaler as the autoscaler focuses solely on the Jacobian and thus often over-scales the problem.\n", + "\n", + "However, we might be able to do better by using other constraint scaling schemes, but before we start experimenting we should stop and think about what sort of scaling might make sense for each constraint. We should always also keep in the back of our minds whether additional work is worth the effort, and if we risk over-tuning the scaling for the specific property package we have.\n", + "\n", + "Fortunately, the model in this example is fairly simple and we do not have too many constraints to consider. Firstly, we have the unit-level constraint that says that `rate_reaction == 0` for all rate-based reactions. When considering scaling of a constraint we should ignore any 0 terms, thus this constraint has only 1 term and so we should scale based on this. If we use the ``scale_constraint_by_nominal_value`` method for this it will ignore the zero for us, the scheme used does not actually matter as there is only one term to consider.\n", + "\n", + "Next, we have the balance equations which all have the form `0 == In - Out + Gen` - note the equilibrium reactor does not support dynamics so we don't need to think about that. Generation terms can vary a lot, but we basically have two possible cases:\n", + "\n", + "1. one term is negligible compared to the other 2, so we should scale based on one of the significant\n", + "terms, or\n", + "2. all three terms are of similar significance (e.g., inlet and gen are of similar scale and outlet\n", + "is ~inletx2). Here we could scale based on the harmonic mean, by the maximum term is probably not bad either.\n", + "\n", + "So, in short the maximum magnitude is probably the best general-purpose scale for these constraints.\n", + "\n", + "Finally, we have stoichiometric constraints with the form `G[j, r] == n[j, r]*X[r]` where ``G`` is generation, ``X`` is extent and ``n`` is the stoichiometric coefficient (i.e., a constant) - these are simple ``A=B`` constraints, so scaling by maximum magnitude is equivalent to other methods (as there are only two terms which will take the same value, all schemes will give the same result in the end).\n", + "\n", + "So, for the equilibrium reactor at least, we are probably best leaving things as they are.\n", + "\n", + "However, there is one important test left. The whole purpose of a scaling routine is to allow us to perturb the model and solve it at the new state so we should test to confirm that our new ``Scaler`` has improved the performance of our solver when solving for the perturbed state we tried earlier. This also lets us see how the new ``Scaler`` will look for a user trying to apply the tool, which we can see below." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Ipopt 3.13.2: linear_solver=ma57\n", + "max_iter=200\n", + "nlp_scaling_method=gradient-based\n", + "tol=1e-06\n", + "\n", + "\n", + "******************************************************************************\n", + "This program contains Ipopt, a library for large-scale nonlinear optimization.\n", + " Ipopt is released as open source code under the Eclipse Public License (EPL).\n", + " For more information visit http://projects.coin-or.org/Ipopt\n", + "\n", + "This version of Ipopt was compiled from source code available at\n", + " https://github.com/IDAES/Ipopt as part of the Institute for the Design of\n", + " Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE\n", + " Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.\n", + "\n", + "This version of Ipopt was compiled using HSL, a collection of Fortran codes\n", + " for large-scale scientific computation. All technical papers, sales and\n", + " publicity material resulting from use of the HSL codes within IPOPT must\n", + " contain the following acknowledgement:\n", + " HSL, a collection of Fortran codes for large-scale scientific\n", + " computation. See http://www.hsl.rl.ac.uk.\n", + "******************************************************************************\n", + "\n", + "This is Ipopt version 3.13.2, running with linear solver ma57.\n", + "\n", + "Number of nonzeros in equality constraint Jacobian...: 21\n", + "Number of nonzeros in inequality constraint Jacobian.: 0\n", + "Number of nonzeros in Lagrangian Hessian.............: 9\n", + "\n", + "Total number of variables............................: 8\n", + " variables with only lower bounds: 5\n", + " variables with lower and upper bounds: 1\n", + " variables with only upper bounds: 0\n", + "Total number of equality constraints.................: 8\n", + "Total number of inequality constraints...............: 0\n", + " inequality constraints with only lower bounds: 0\n", + " inequality constraints with lower and upper bounds: 0\n", + " inequality constraints with only upper bounds: 0\n", + "\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 0 0.0000000e+00 5.53e+02 1.00e+00 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0\n", + "Reallocating memory for MA57: lfact (247)\n", + " 1 0.0000000e+00 5.53e+02 1.20e+00 -1.0 9.95e+02 - 2.00e-05 1.96e-05h 1\n", + " 2 0.0000000e+00 5.53e+02 1.20e+00 -1.0 9.57e+02 - 2.06e-05 2.00e-05h 1\n", + " 3 0.0000000e+00 5.53e+02 7.36e+01 -1.0 9.25e+02 - 4.36e-04 4.06e-05h 1\n", + " 4 0.0000000e+00 5.53e+02 3.34e+05 -1.0 8.55e+02 - 2.41e-04 1.21e-03f 1\n", + " 5 0.0000000e+00 5.40e+02 6.59e+03 -1.0 9.98e+01 - 2.25e-04 2.34e-02f 1\n", + " 6 0.0000000e+00 5.24e+02 1.11e+08 -1.0 9.74e+01 - 2.54e-02 2.84e-02f 1\n", + " 7 0.0000000e+00 2.36e+02 2.03e+06 -1.0 9.47e+01 - 7.09e-02 5.49e-01h 1\n", + " 8 0.0000000e+00 8.62e+01 6.37e+10 -1.0 4.27e+01 - 8.23e-03 6.35e-01h 1\n", + " 9 0.0000000e+00 1.96e+00 4.93e+10 -1.0 1.56e+01 - 1.00e+00 1.00e+00h 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 10 0.0000000e+00 2.05e-04 2.15e+09 -1.0 3.70e-02 - 1.00e+00 1.00e+00h 1\n", + " 11 0.0000000e+00 6.28e-15 2.56e+05 -1.0 2.05e-04 - 1.00e+00 1.00e+00h 1\n", + "\n", + "Number of Iterations....: 11\n", + "\n", + " (scaled) (unscaled)\n", + "Objective...............: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Dual infeasibility......: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Constraint violation....: 6.2780236478193237e-15 6.2780236478193237e-15\n", + "Complementarity.........: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Overall NLP error.......: 6.2780236478193237e-15 6.2780236478193237e-15\n", + "\n", + "\n", + "Number of objective function evaluations = 12\n", + "Number of objective gradient evaluations = 12\n", + "Number of equality constraint evaluations = 12\n", + "Number of inequality constraint evaluations = 0\n", + "Number of equality constraint Jacobian evaluations = 12\n", + "Number of inequality constraint Jacobian evaluations = 0\n", + "Number of Lagrangian Hessian evaluations = 11\n", + "Total CPU secs in IPOPT (w/o function evaluations) = 0.000\n", + "Total CPU secs in NLP function evaluations = 0.000\n", + "\n", + "EXIT: Optimal Solution Found.\n" + ] + } + ], + "source": [ + "m = build_model()\n", + "\n", + "scaler = EquilibriumReactorScaler()\n", + "scaler.scale_model(m.fs.equil)\n", + "\n", + "perturb_model(m)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that by applying our new ``EquilibriumReactorScaler`` we are now able to use IPOPT to solve for the perturbation, and that it reaches an optimal solution in 11 iterations. Looking at the solver logs we can see that the solver step lengths (``alpha_du`` and ``alpha_pr``) are rather small for the first iterations but the number of line searches (``ls``) is 1 for all iterations. This indicates that IPOPT is pushing up against some bound or constraint and cannot make full steps, but in this case it is due to the fact that to achieve equilibrium for an irreversible reaction at least one concentration must be driven to zero (and is why an EquibriumReactor is probably not a good choice for this test case). However, the fact that our ``Scaler`` let us solve for this challenging test case is probably a good sign.\n", + "\n", + "\n", + "## Step 8: Finishing Up\n", + "\n", + "Ideally, we would have more than one test case to apply our ``Scaler`` to put it through its paces and ensure it is robust across a wide range of conditions. However, for the purposes of this workshop we will move on.\n", + "\n", + "Once you are satisfied that your ``Scaler`` is ready, you can start applying it to actual problems of interest. For those modelers developing new unit and property models, you should assign your new ``Scaler`` as the default scaler for that unit model. You can do this by setting the ``default_scaler`` attribute on your model to point to the new ``Scaler`` as shown below.\n", + "\n", + "```python\n", + "@declare_process_block_class(\"EquilibriumReactor\")\n", + "class EquilibriumReactorData(UnitModelBlockData):\n", + " \"\"\"\n", + " Standard Equilibrium Reactor Unit Model Class\n", + " \"\"\"\n", + "\n", + " # Setting the default_scaler attribute\n", + " default_scaler = EquilibriumReactorScaler\n", + "```\n", + "\n", + "With that, we have finished this workshop on developing ``Scaler`` classes. Hopefully you now know enough to begin writing ``Scalers`` for your own models, and have gained some insight into how to think about developing scaling routines and the tools available to help you." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "celltoolbar": "Tags", + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 3 +} \ No newline at end of file diff --git a/idaes_examples/notebooks/docs/scaling/scaler_workshop_test.ipynb b/idaes_examples/notebooks/docs/scaling/scaler_workshop_test.ipynb new file mode 100644 index 00000000..fddb4930 --- /dev/null +++ b/idaes_examples/notebooks/docs/scaling/scaler_workshop_test.ipynb @@ -0,0 +1,1965 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [ + "header", + "hide-cell" + ] + }, + "outputs": [], + "source": [ + "###############################################################################\n", + "# The Institute for the Design of Advanced Energy Systems Integrated Platform\n", + "# Framework (IDAES IP) was produced under the DOE Institute for the\n", + "# Design of Advanced Energy Systems (IDAES).\n", + "#\n", + "# Copyright (c) 2018-2023 by the software owners: The Regents of the\n", + "# University of California, through Lawrence Berkeley National Laboratory,\n", + "# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon\n", + "# University, West Virginia University Research Corporation, et al.\n", + "# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md\n", + "# for full copyright and license information.\n", + "###############################################################################" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to Create Scaler Objects in IDAES\n", + "\n", + "Author: Andrew Lee\n", + "Maintainer: Doug Allan\n", + "Updated: 2024-10-24\n", + "\n", + "## Introduction\n", + "\n", + "
\n", + "NOTE All the suggestions in this introduction should be viewed as \"rules-of-thumb\" and not taken as absolute guidance. There are many cases where alternative approaches may give as-good or better results and you should always consider the meaning of the scaling factors you are applying and how they affect the solver's behavior. \n", + "
\n", + "\n", + "Solving general non-linear problems has always been challenging, and is highly dependent on how well scaled the model is. In many cases, as much time (or more) is spent trying to improve the model formulation and scaling as was spent writing the original model. To assist molders with this task, IDAES has implemented a Scaling Toolbox which contains a number of useful tools for common scaling techniques as well as a standard interface and form for how to write scaling routines.\n", + "\n", + "The goal of this workshop is to take you through the process of writing a general-purpose, modular scaling routine for an equilibrium reactor example. By the end of this exercise you should:\n", + "\n", + "* understand the ``CustomScalerBase`` class and how to apply the tools it contains,\n", + "* understand how to use ``CustomScalerBase`` to set up a modular scaling routine for a model,\n", + "* understand how to use the Diagnostics Toolbox to check for scaling issues in a model.\n", + "\n", + "## How to Write a Scaling Routine\n", + "\n", + "
The golden rule when developing a scaling routine to a model is to always think about what you are doing and why. Bad scaling is often worse than no scaling at all, so assigning arbitrary scaling factors should be avoided. Always start by taking the time to look over the model you want to scale and understand what variables and constraints are present. For variables, you should ask yourself what the expected range of magnitudes will be; assigning an arbitrary default value should be avoided. For constraints you should ask yourself what the expected magnitude of each additive term will be, how much these vary from each other, and which term is likely to be most significant in terms of variation (partial derivatives).
\n", + "\n", + "
\n", + "NOTE Different solvers behave in different ways, and you may find cases where tuning scaling for one solver results in worse performance for another.\n", + " \n", + "You should always consider the end-goal when writing a Scaler; if you are writing a routine for a specific application and solver then you may wish to tune the scaling factors for best performance, however if you are writing a general-purpose Scaler then you should aim for scaling that will work for a wide range of conditions and solver.\n", + "
\n", + "\n", + "Below are some general suggestions for developing scaling routines.\n", + " \n", + "* Order of magnitude estimates are generally good enough (and often better than exact values).\n", + "* Start with what you know the most about, and work out from there.\n", + "* If in doubt, start by scaling variables first, and then scale constraints based on the variable scaling.\n", + "* Be judicious when applying scaling factors for things you are uncertain about. If in doubt, leave a component unscaled and see what the model diagnostics have to say.\n", + "* Make use of the modular nature of IDAES when writing scaling routines. A unit model developer might not know the expected magnitude of the thermophysical properties they get from a property package, but there should be a scaling routine for the property package that they can call to provide these.\n", + "\n", + "
\n", + "NOTE When dealing with systems of partial differential algebraic equations (PDAEs), such as dynamic systems or those with spatial variation, it is important to consider how scaling may change across the discretized domain. In many of these types of models, you will find significant changes in scale across a small portion of the domain; for example a dynamic model of a step disturbance will show an initial equilibrium state followed by a rapid change in system conditions until a new equilibrium is established. To complicate things further, the location of this ramp can often move significantly with minor changes in system conditions, thus you should not presume that the ramp will remain in the same place.\n", + " \n", + "As a general rule, for scaling PDAE systems with significant changes, you should focus on finding a set of scaling factors that is suitable for the ramp region as this is the part of the model which will be hardest to solve.\n", + "
\n", + "\n", + "### IDAES Scaling Interface and Toolbox\n", + "\n", + "IDAES uses a class-based interface for defining scaling routines, where model developers can create ``Scaler`` objects which define a scaling routine suitable for a type of model or specific application. All models (both those in the IDAES model libraries and user-developed models) should have one or more ``Scaler`` classes defined for them that can be used to apply scaling routines to the model. To assist end-users in identifying a suitable ``Scaler`` for a model, all IDAES models have a ``default_scaler`` attribute which can be set to point to a ``Scaler`` object suitable for that model. Model developers should endeavor to create a reliable, general-purpose ``Scaler`` for each model they create and assign this as the default ``Scaler``. We will demonstrate how to do this at the end of this workshop.\n", + "\n", + "\n", + "## Step 1: Set Up Test Case(s)\n", + "\n", + "Whilst it is possible to develop a scaling routine by looking only at the model code and the resulting variables and constraints, in order to test it we will need one or more test cases to run. These test cases are important for both checking the that ``Scaler`` code runs as expected, and that it also improves the scaling of the model. The more test cases you can check against, the more confident you can be that the ``Scaler`` you have written is suitable for a wide range of applications.\n", + "\n", + "For this example we will develop a general purpose ``Scaler`` for the ``EquilibriumReactor`` model from the core IDAES model library using the saponification property and reaction packages as a test case. The code below imports the necessary packages and creates a function that will build and initialize our test case." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import ConcreteModel, Constraint, units, Var\n", + "\n", + "from idaes.core import FlowsheetBlock\n", + "from idaes.models.unit_models.equilibrium_reactor import (\n", + " EquilibriumReactor,\n", + ")\n", + "from idaes.models.properties.examples.saponification_thermo import (\n", + " SaponificationParameterBlock,\n", + ")\n", + "from idaes.models.properties.examples.saponification_reactions import (\n", + " SaponificationReactionParameterBlock,\n", + ")\n", + "from idaes.core.solvers import get_solver\n", + "from idaes.core.initialization import BlockTriangularizationInitializer\n", + "from idaes.core.util import DiagnosticsToolbox\n", + "\n", + "\n", + "def build_model():\n", + " m = ConcreteModel()\n", + " m.fs = FlowsheetBlock(dynamic=False)\n", + "\n", + " m.fs.properties = SaponificationParameterBlock()\n", + " m.fs.reactions = SaponificationReactionParameterBlock(\n", + " property_package=m.fs.properties\n", + " )\n", + "\n", + " m.fs.equil = EquilibriumReactor(\n", + " property_package=m.fs.properties,\n", + " reaction_package=m.fs.reactions,\n", + " has_equilibrium_reactions=False,\n", + " has_heat_transfer=True,\n", + " has_heat_of_reaction=True,\n", + " has_pressure_change=True,\n", + " )\n", + "\n", + " m.fs.equil.inlet.flow_vol[0].fix(1.0e-03 * units.m**3 / units.s)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"H2O\"].fix(55388.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"NaOH\"].fix(100.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"EthylAcetate\"].fix(\n", + " 100.0 * units.mol / units.m**3\n", + " )\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"SodiumAcetate\"].fix(0.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"Ethanol\"].fix(0.0 * units.mol / units.m**3)\n", + "\n", + " m.fs.equil.inlet.temperature[0].fix(303.15 * units.K)\n", + " m.fs.equil.inlet.pressure[0].fix(101325.0 * units.Pa)\n", + "\n", + " m.fs.equil.heat_duty.fix(0 * units.W)\n", + " m.fs.equil.deltaP.fix(0 * units.Pa)\n", + "\n", + " initializer = BlockTriangularizationInitializer()\n", + " initializer.initialize(m.fs.equil)\n", + "\n", + " return m" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Before we move on to try to solve the model or develop a ``Scaler``, we should first check to make sure the model is well-posed and that there are not any structural issues that will prevent us from solving the model. The code below creates an instance of the IDAES Diagnostics Toolbox and runs the ``report_structural_issues`` method to ensure there are no warnings." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Activated Blocks: 5 (Deactivated: 0)\n", + " Free Variables in Activated Constraints: 16 (External: 0)\n", + " Free Variables with only lower bounds: 6\n", + " Free Variables with only upper bounds: 0\n", + " Free Variables with upper and lower bounds: 2\n", + " Fixed Variables in Activated Constraints: 10 (External: 0)\n", + " Activated Equality Constraints: 16 (Deactivated: 0)\n", + " Activated Inequality Constraints: 0 (Deactivated: 0)\n", + " Activated Objectives: 0 (Deactivated: 0)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "0 WARNINGS\n", + "\n", + " No warnings found!\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 Cautions\n", + "\n", + " Caution: 4 variables fixed to 0\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " Try to initialize/solve your model and then call report_numerical_issues()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "m = build_model()\n", + "\n", + "dt = DiagnosticsToolbox(model=m.fs.equil)\n", + "dt.report_structural_issues()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# Make sure base model is constructed properly\n", + "dt.assert_no_structural_warnings()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In order to fully test our new ``Scaler`` it is also useful to test how the model responds to perturbations in the state. In many ways, this is the real test of a scaling routine as it is easy to write something that gets good scaling for a known state (e.g., auto-scalers), but what we really need is a routine that can get good scaling across a range of conditions.\n", + "\n", + "The cell below creates a function that perturbs the state of our model significantly. Note that the volumetric flowrate has been increased by two orders of magnitude, the inlet concentrations have changed significantly, and we have also made a small change to the temperature." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.solvers import get_solver\n", + "\n", + "solver = get_solver(\n", + " \"ipopt_v2\", writer_config={\"scale_model\": True, \"linear_presolve\": True}\n", + ")\n", + "\n", + "\n", + "def perturb_model(m):\n", + " m.fs.equil.inlet.flow_vol.fix(1 * units.m**3 / units.s)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"NaOH\"].fix(200.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"EthylAcetate\"].fix(\n", + " 100.0 * units.mol / units.m**3\n", + " )\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"SodiumAcetate\"].fix(50 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"Ethanol\"].fix(1e-8 * units.mol / units.m**3)\n", + "\n", + " m.fs.equil.inlet.temperature.fix(320 * units.K)\n", + " solver.solve(m, tee=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets apply this perturbation to our example model and see how well it solves." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Ipopt 3.13.2: linear_solver=ma57\n", + "max_iter=200\n", + "nlp_scaling_method=gradient-based\n", + "tol=1e-06\n", + "\n", + "\n", + "******************************************************************************\n", + "This program contains Ipopt, a library for large-scale nonlinear optimization.\n", + " Ipopt is released as open source code under the Eclipse Public License (EPL).\n", + " For more information visit http://projects.coin-or.org/Ipopt\n", + "\n", + "This version of Ipopt was compiled from source code available at\n", + " https://github.com/IDAES/Ipopt as part of the Institute for the Design of\n", + " Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE\n", + " Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.\n", + "\n", + "This version of Ipopt was compiled using HSL, a collection of Fortran codes\n", + " for large-scale scientific computation. All technical papers, sales and\n", + " publicity material resulting from use of the HSL codes within IPOPT must\n", + " contain the following acknowledgement:\n", + " HSL, a collection of Fortran codes for large-scale scientific\n", + " computation. See http://www.hsl.rl.ac.uk.\n", + "******************************************************************************\n", + "\n", + "This is Ipopt version 3.13.2, running with linear solver ma57.\n", + "\n", + "Number of nonzeros in equality constraint Jacobian...: 21\n", + "Number of nonzeros in inequality constraint Jacobian.: 0\n", + "Number of nonzeros in Lagrangian Hessian.............: 9\n", + "\n", + "Total number of variables............................: 8\n", + " variables with only lower bounds: 5\n", + " variables with lower and upper bounds: 1\n", + " variables with only upper bounds: 0\n", + "Total number of equality constraints.................: 8\n", + "Total number of inequality constraints...............: 0\n", + " inequality constraints with only lower bounds: 0\n", + " inequality constraints with lower and upper bounds: 0\n", + " inequality constraints with only upper bounds: 0\n", + "\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 0 0.0000000e+00 9.09e+07 1.00e+00 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0\n", + "Reallocating memory for MA57: lfact (247)\n", + " 1r 0.0000000e+00 9.09e+07 9.99e+02 2.5 0.00e+00 - 0.00e+00 7.73e-09R 9\n", + " 2r 0.0000000e+00 8.42e+07 8.24e+03 2.5 7.20e+02 - 1.64e-02 2.59e-02f 1\n", + " 3r 0.0000000e+00 8.37e+07 7.72e+03 1.8 8.82e+04 - 5.56e-04 3.77e-05f 1\n", + " 4r 0.0000000e+00 3.76e+07 2.65e+04 1.8 1.13e+03 0.0 1.27e-01 1.63e-01f 1\n", + " 5r 0.0000000e+00 3.60e+07 2.30e+04 1.8 6.83e+01 1.3 7.53e-02 1.45e-01f 1\n", + " 6r 0.0000000e+00 4.17e+07 1.77e+04 1.8 2.10e+02 0.9 7.11e-02 1.47e-01f 1\n", + " 7r 0.0000000e+00 4.08e+07 1.75e+04 1.8 3.95e+02 0.4 2.35e-01 8.19e-03f 1\n", + " 8r 0.0000000e+00 3.13e+07 1.75e+04 1.8 1.12e+03 -0.1 3.57e-01 3.16e-02f 1\n", + " 9r 0.0000000e+00 7.77e+06 1.74e+04 1.8 1.00e+04 - 1.43e-02 9.06e-03f 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 10r 0.0000000e+00 7.36e+06 1.70e+04 1.8 2.14e+02 - 2.20e-01 2.38e-02f 1\n", + " 11r 0.0000000e+00 5.93e+06 1.67e+04 1.8 1.72e+02 - 7.89e-01 1.95e-01f 1\n", + " 12r 0.0000000e+00 1.54e+06 3.54e+04 1.8 1.06e+02 - 8.80e-01 7.41e-01f 1\n", + " 13r 0.0000000e+00 1.21e+06 2.79e+04 1.8 4.60e+00 - 1.00e+00 2.12e-01h 1\n", + " 14r 0.0000000e+00 3.31e+03 4.79e+01 1.8 2.39e+00 - 1.00e+00 1.00e+00f 1\n", + " 15r 0.0000000e+00 2.85e+03 7.72e+02 -0.2 2.01e+00 - 9.81e-01 9.09e-01f 1\n", + " 16r 0.0000000e+00 2.47e+03 6.60e+02 -0.2 9.87e-01 - 1.00e+00 1.48e-01f 1\n", + " 17r 0.0000000e+00 3.18e-01 8.47e+01 -0.2 1.39e-01 - 1.00e+00 1.00e+00f 1\n", + " 18r 0.0000000e+00 3.18e-01 6.25e+01 -0.2 1.96e-01 - 1.00e+00 1.00e+00f 1\n", + " 19r 0.0000000e+00 3.18e-01 5.46e+00 -0.2 3.26e-02 - 1.00e+00 1.00e+00h 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 20r 0.0000000e+00 3.18e-01 1.44e+02 -1.6 2.34e-01 - 1.00e+00 1.00e+00f 1\n", + " 21r 0.0000000e+00 3.18e-01 1.45e+01 -1.6 9.26e-02 - 9.13e-01 1.00e+00f 1\n", + " 22r 0.0000000e+00 3.18e-01 1.46e+01 -1.6 1.71e-01 - 1.00e+00 1.25e-01f 4\n", + " 23r 0.0000000e+00 3.18e-01 1.44e+01 -1.6 1.24e-01 - 1.00e+00 1.56e-02h 7\n", + " 24r 0.0000000e+00 3.18e-01 1.41e+01 -1.6 1.27e-01 - 1.00e+00 1.56e-02h 7\n", + " 25r 0.0000000e+00 3.18e-01 1.39e+01 -1.6 1.24e-01 - 1.00e+00 1.56e-02h 7\n", + " 26r 0.0000000e+00 3.18e-01 1.37e+01 -1.6 1.22e-01 - 1.00e+00 1.56e-02h 7\n", + " 27r 0.0000000e+00 3.18e-01 1.35e+01 -1.6 1.20e-01 - 1.00e+00 1.56e-02h 7\n", + " 28r 0.0000000e+00 3.18e-01 1.33e+01 -1.6 1.18e-01 - 1.00e+00 1.56e-02h 7\n", + " 29r 0.0000000e+00 3.18e-01 1.31e+01 -1.6 1.17e-01 - 1.00e+00 1.56e-02h 7\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 30r 0.0000000e+00 3.18e-01 1.29e+01 -1.6 1.15e-01 - 1.00e+00 1.56e-02h 7\n", + " 31r 0.0000000e+00 3.18e-01 1.28e+01 -1.6 1.13e-01 - 1.00e+00 1.56e-02h 7\n", + " 32r 0.0000000e+00 3.18e-01 4.28e+01 -1.6 1.11e-01 - 1.00e+00 1.00e+00w 1\n", + " 33r 0.0000000e+00 3.18e-01 1.43e-03 -1.6 2.09e-05 - 1.00e+00 1.00e+00w 1\n", + " 34r 0.0000000e+00 3.18e-01 1.37e+01 -3.7 6.94e-02 - 1.00e+00 1.00e+00f 1\n", + " 35r 0.0000000e+00 3.17e-01 3.73e+04 -3.7 3.39e+00 - 1.44e-01 1.00e+00f 1\n", + " 36r 0.0000000e+00 3.17e-01 6.78e+03 -3.7 5.99e-01 - 1.00e+00 1.00e+00f 1\n", + " 37r 0.0000000e+00 3.17e-01 7.66e+00 -3.7 4.92e-03 - 1.00e+00 1.00e+00h 1\n", + " 38r 0.0000000e+00 3.17e-01 1.65e-04 -3.7 9.43e-05 - 1.00e+00 1.00e+00h 1\n", + " 39r 0.0000000e+00 3.17e-01 1.30e+00 -5.6 9.94e-04 - 1.00e+00 1.00e+00f 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 40r 0.0000000e+00 3.11e-01 6.82e+04 -5.6 3.21e+01 - 1.41e-01 1.00e+00f 1\n", + " 41r 0.0000000e+00 3.11e-01 1.17e+01 -5.6 4.97e-03 - 1.00e+00 1.00e+00h 1\n", + "\n", + "Number of Iterations....: 41\n", + "\n", + " (scaled) (unscaled)\n", + "Objective...............: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Dual infeasibility......: 2.2783833299154238e-01 2.2783833299154238e-01\n", + "Constraint violation....: 3.1132475345243688e-01 3.1132475345243688e-01\n", + "Complementarity.........: 2.7808801399127131e-06 2.7808801399127131e-06\n", + "Overall NLP error.......: 3.1132475345243688e-01 3.1132475345243688e-01\n", + "\n", + "\n", + "Number of objective function evaluations = 109\n", + "Number of objective gradient evaluations = 3\n", + "Number of equality constraint evaluations = 109\n", + "Number of inequality constraint evaluations = 0\n", + "Number of equality constraint Jacobian evaluations = 44\n", + "Number of inequality constraint Jacobian evaluations = 0\n", + "Number of Lagrangian Hessian evaluations = 42\n", + "Total CPU secs in IPOPT (w/o function evaluations) = 0.008\n", + "Total CPU secs in NLP function evaluations = 0.000\n", + "\n", + "EXIT: Converged to a point of local infeasibility. Problem may be infeasible.\n" + ] + } + ], + "source": [ + "perturb_model(m)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As can be seen from the solver logs, IPOPT was unable to find a feasible solution to this problem, and went into restoration from the first iteration. However, there is no reason the perturbed conditions should not be feasible (you can verify this with the `infeasibility_explainer` in the Diagnostics Toolbox if you desire).\n", + "\n", + "There are a few reasons for this, most of which can be resolved by providing better scaling for the model. One of the reasons is because we have a number of concentrations approaching zero which results in a number of very small numbers appearing in the problem.\n", + "\n", + "A bigger issue however is the fact that in our initial model we are feeding reactants in stoichiometric amounts (1:1) meaning that both reactant concentrations go to zero at equilibrium. This results in the Jacobian for the reaction rate constraint becoming singular; with `rate = K_rxn * [NaOH] * [EthylAcetate]` if both concentrations go to zero then the partial derivative of the reaction rate with respect to each concentration is also 0, and thus our solver has no idea of what direction to move when trying to converge the problem. Whilst scaling can help work around this, this is ultimately an indication that our problem is not well formulated. In practice, an Equilibrium reactor model is not well suited for systems involving irreversible rate-based reactions as it requires concentrations to be driven to zero, and is an especially poor choice for stoichiometric feeds." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2: Understanding the Model\n", + "\n", + "Now that we have a test case (or multiple test cases), we can start planning out the new scaling routine. As our goal is to estimate scaling factors for as many of the variables and constraints in the model as possible, the first step is to understand what variables and constraints may be present in the model. Note that we need to be careful to check for all variables and constraints that may exist under different configuration options, and not just those that appear in the our test case(s).\n", + "\n", + "Given the modular nature of IDAES, we need to also make a distinction between those variables and constraints we have direct knowledge of, and those that are created via modular sub-models that we do not know the details of. The most common examples of modular sub-models are the ``StateBlocks`` and ``ReactionBlocks`` created by the associated property packages; we know that these exist and we create these in our models, but we do not know what variables and constraints they may construct. On the other hand, we also have variables and constraints that we construct directly in our model. For the purposes of this we include those variables and constraints constructed by ``ControlVolumes`` as being directly construed; whilst the ``ControlVolume`` might automate the details for us, we directly call methods on the ``ControlVolume`` to create these variables and constraints and we know what they will be based on the instructions we give.\n", + "\n", + "For our example of the ``EquilibriumReactor``, let us take a look at the code in the ``build`` method, which has been copied below for convenience:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "def build(self):\n", + " \"\"\"\n", + " Begin building model.\n", + "\n", + " Args:\n", + " None\n", + "\n", + " Returns:\n", + " None\n", + " \"\"\"\n", + " # Call UnitModel.build to setup dynamics\n", + " super(EquilibriumReactorData, self).build()\n", + "\n", + " # Build Control Volume\n", + " self.control_volume = ControlVolume0DBlock(\n", + " dynamic=self.config.dynamic, # Config block forces this to be False\n", + " has_holdup=self.config.has_holdup, # Config block forces this to be False\n", + " property_package=self.config.property_package,\n", + " property_package_args=self.config.property_package_args,\n", + " reaction_package=self.config.reaction_package,\n", + " reaction_package_args=self.config.reaction_package_args,\n", + " )\n", + "\n", + " # No need for control volume geometry\n", + "\n", + " self.control_volume.add_state_blocks(\n", + " has_phase_equilibrium=self.config.has_phase_equilibrium\n", + " )\n", + "\n", + " self.control_volume.add_reaction_blocks(\n", + " has_equilibrium=self.config.has_equilibrium_reactions\n", + " )\n", + "\n", + " self.control_volume.add_material_balances(\n", + " balance_type=self.config.material_balance_type,\n", + " has_rate_reactions=self.config.has_rate_reactions,\n", + " has_equilibrium_reactions=self.config.has_equilibrium_reactions,\n", + " has_phase_equilibrium=self.config.has_phase_equilibrium,\n", + " )\n", + "\n", + " self.control_volume.add_energy_balances(\n", + " balance_type=self.config.energy_balance_type,\n", + " has_heat_of_reaction=self.config.has_heat_of_reaction,\n", + " has_heat_transfer=self.config.has_heat_transfer,\n", + " )\n", + "\n", + " self.control_volume.add_momentum_balances(\n", + " balance_type=self.config.momentum_balance_type,\n", + " has_pressure_change=self.config.has_pressure_change,\n", + " )\n", + "\n", + " # Add Ports\n", + " self.add_inlet_port()\n", + " self.add_outlet_port()\n", + "\n", + " if self.config.has_rate_reactions:\n", + " # Add equilibrium reactor performance equation\n", + " @self.Constraint(\n", + " self.flowsheet().time,\n", + " self.config.reaction_package.rate_reaction_idx,\n", + " doc=\"Rate reaction equilibrium constraint\",\n", + " )\n", + " def rate_reaction_constraint(b, t, r):\n", + " # Set kinetic reaction rates to zero\n", + " return b.control_volume.reactions[t].reaction_rate[r] == 0\n", + "\n", + " # Set references to balance terms at unit level\n", + " if (\n", + " self.config.has_heat_transfer is True\n", + " and self.config.energy_balance_type != EnergyBalanceType.none\n", + " ):\n", + " self.heat_duty = Reference(self.control_volume.heat[:])\n", + "\n", + " if (\n", + " self.config.has_pressure_change is True\n", + " and self.config.momentum_balance_type != MomentumBalanceType.none\n", + " ):\n", + " self.deltaP = Reference(self.control_volume.deltaP[:])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If we look through the code in the ``build`` method, we can see that the model contains a single 0D Control Volume with ``StateBlocks``, a ``ReactionBlock``, material, energy and momentum balances and one additional constraint (``rate_reaction_constraint``). Thus, we have the following components that need to be scaled:\n", + "\n", + "3 Sub-Models:\n", + "\n", + "1. The inlet state sub-model (``model.control_volume.properties_in``)\n", + "2. The outlet state sub-model (``model.control_volume.properties_out``)\n", + "3. The reaction sub-model (``model.control_volume.reactions``)\n", + "\n", + "Unit Model Variables (from control volume options):\n", + "\n", + "1. Rate-based reaction extent and generation terms\n", + "2. Equilibrium-based reaction extent and generation terms\n", + "3. Inherent reaction extent and generation terms (no explicit argument, but determined by properties)\n", + "4. Phase equilibrium generation terms\n", + "5. Energy balance heat term\n", + "6. Energy balance heats of reaction\n", + "7. Pressure drop\n", + "\n", + "Unit Model Constraints (from control volume + 1 in the ``build`` method):\n", + "\n", + "1. Material balance constraints\n", + "2. Reaction stoichiometry constraints\n", + "3. Energy balance constraints\n", + "4. Pressure balance constraints\n", + "5. ``rate_reaction_constraint``\n", + "\n", + "When writing our ``Scaler`` we will need to consider all of these to determine how best to estimate scaling factors. Before starting however, we should check the numerical diagnostics for each case study, both to see what scaling issues currently exist and to establish a baseline for comparison once we have a proposed ``Scaler`` for our model.\n", + "\n", + "The cell below calls the ``report_numerical_issues`` method for the unscaled test case." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 1.540E+12\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 1 Constraint with large residuals (>1.0E-05)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "7 Cautions\n", + "\n", + " Caution: 1 Variable with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme value (<1.0E-04 or >1.0E+04)\n", + " Caution: 1 Constraint with mismatched terms\n", + " Caution: 3 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_constraints_with_large_residuals()\n", + " compute_infeasibility_explanation()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "dt.report_numerical_issues()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Looking at the results of the diagnostics, we can see that the test case is not particularly well scaled. The Jacobian condition number is rather large (1e12), and the diagnostics are reporting a number of variables with extremely large or small values, and 3 variables and 2 constraints with poorly scaled Jacobians. As we develop our new ``Scaler`` for the ``EquilibriumReactor`` we will hopefully see these improve.\n", + "\n", + "We can also use the Diagnostics Toolbox to further explore these issues to get a better idea of which variables and constraints might be causing issues. For example, lets display the set of variables and constraints with extreme Jacobian norms." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "The following variable(s) are associated with extreme Jacobian values (<1.0E-04 or>1.0E+04):\n", + "\n", + " fs.equil.control_volume.properties_out[0.0].flow_vol: 9.427E+07\n", + " fs.equil.control_volume.properties_out[0.0].temperature: 4.172E+06\n", + " fs.equil.control_volume.rate_reaction_extent[0.0,R1]: 4.900E+04\n", + "\n", + "====================================================================================\n", + "====================================================================================\n", + "The following constraint(s) are associated with extreme Jacobian values (<1.0E-04 or>1.0E+04):\n", + "\n", + " fs.equil.control_volume.enthalpy_balances[0.0]: 9.436E+07\n", + " fs.equil.control_volume.material_balances[0.0,Liq,H2O]: 5.539E+04\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "dt.display_variables_with_extreme_jacobians()\n", + "dt.display_constraints_with_extreme_jacobians()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "These diagnostics can help give us an idea of what may be causing problems in our model. From the output above, we can see that the variables with large Jacobian norms (i.e., high sensitivities) are the outlet flow rate and temperature, as well as the rate-based extent of reaction. We can also see that the constraints with large Jacobian norms are the enthalpy balance and H20 material balance for the reactor. However, caution must be used when interpreting these in isolation, as understanding what these mean is often complicated and initial impressions may be misleading. To get a better picture of what is contributing to extreme Jacobian values you should make use of the tools in the diagnostics ``SVDToolbox``, however that is a topic for another example.\n", + "\n", + "For example, one might wonder why the volumetric flow rate at the outlet of the reactor is so important as it is effectively determined by the inlet flow rate (due to the water balance effectively conserving volume). However, it is important to remember that the Jacobian does not consider the value of the variable, but rather its partial derivatives. Thus, it is important to compare the list of variables and constraints with large Jacobian norms and think about how those intersect.\n", + "\n", + "Let's start by taking a look at the H2O material balance. The cell below prints the constraint expression in a compact form that only shows top level ``Expressions`` rather than expanding these to show the full expression tree." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fs.equil.control_volume.properties_in[0.0].flow_vol*fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] - fs.equil.control_volume.properties_out[0.0].flow_vol*fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] + fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] == 0" + ] + } + ], + "source": [ + "from idaes.core.util.misc import print_compact_form\n", + "\n", + "print_compact_form(m.fs.equil.control_volume.material_balances[0, \"Liq\", \"H2O\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Looking at how the outlet volumetric flowrate appears in the H2O balance equation above, it can be seen that the volumetric flow term is multiplied by the molar concentration of water, $F \\times C_{H2O}$. Whilst $C_{H2O}$ is assumed to be constant in this model (and equal to the molar density of pure water at ambient conditions), this means that the partial derivative of the constraint term with respect to flow is $\\frac{\\partial F C_{H2O}}{\\partial F} = C_{H2O}$; given that $C_{H2O}$ is equal to 5.5E4 mol/liter, you can quickly see why it is being identified as an issue.\n", + "\n", + "If we look at the energy balance, we will find that it is similar." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fs.properties.dens_mol*fs.properties.cp_mol*fs.equil.control_volume.properties_in[0.0].flow_vol*(fs.equil.control_volume.properties_in[0.0].temperature - fs.properties.temperature_ref) - fs.properties.dens_mol*fs.properties.cp_mol*fs.equil.control_volume.properties_out[0.0].flow_vol*(fs.equil.control_volume.properties_out[0.0].temperature - fs.properties.temperature_ref) + fs.equil.control_volume.heat[0.0] + fs.equil.control_volume.heat_of_reaction[0.0] == 0" + ] + } + ], + "source": [ + "print_compact_form(m.fs.equil.control_volume.enthalpy_balances[0.0])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Whilst a bit harder to read due to the size of the constraint, you can see that it involves the term $\\rho \\times c_p \\times F \\times (T - T_{ref})$, where $c_p$ is the specific molar heat capacity of the solution, $T$ is temperature and $T_{ref}$ is the reference temperature. Given that $\\rho$ is of order 1E4 (a) and $c_p \\times (T-T_{ref})$ is of order 1E3, this means that the partial derivative with respect to the volumetric flowrate is even larger than that for the H2O balance. This also explains the appearance of the outlet temperature as well, as we can see that it is multiplied by a number of large values as well and thus has a large partial derivative.\n", + "\n", + "It is also important to mention that having a large value in the Jacobian does not mean a variable is \"important\" (and conversely a small value is not unimportant). What is important is how sensitive the constraint residual is to that change in variable, which is often difficult to assess from the Jacobian alone (which is where the ``SVDToolbox`` can assist).\n", + "\n", + "\n", + "## Step 3: Creating a New Scaler Class\n", + "\n", + "To create a new scaling routine for the equilibrium reactor, we start by creating a new ``Scaler`` class which inherits from the ``CustomScalerBase`` class in ``idaes.core.scaling``. The ``CustomScalerBase`` class contains a number of useful methods to help us in developing our scaling routine, including some placeholder methods for implementing a standard scaling workflow and helper methods for doing common tasks.\n", + "\n", + "The cell below shows how to create our new class which we will name ``EquilibriumReactorScaler`` as well as two key methods we will fill out as part of this workshop." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.scaling import CustomScalerBase\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ``variable_scaling_routine`` and ``constraint_scaling_routine`` methods are used to implement subroutines for scaling the variables and constraints in the model respectively. Separately, there is a ``scale_model`` method that will call each of these in sequence in order to scale an entire model by applying the following steps:\n", + "\n", + "1. apply variable scaling routine,\n", + "2. apply first stage scaling fill-in,\n", + "3. apply constraint scaling routine,\n", + "4. apply second stage scaling fill-in.\n", + "\n", + "The second and fourth steps are intended to allow users to provide methods to fill in missing scaling information that was not provided by the first and second steps, or to provide a way to update the scaling factors with more information.\n", + "\n", + "Both the ``variable_scaling_routine`` and ``constraint_scaling_routine`` are user-facing methods and take three arguments.\n", + "\n", + "1. The model to be scaled.\n", + "2. An argument indicating whether to overwrite any existing scaling factors. Generally we assume that any existing scaling factors were provided by the user for a reason, so by default we set this to ``False``. However, there will likely be cases where a user wants to overwrite their existing scaling factors so this argument exists to let us pass on those instructions.\n", + "3. A mapping of user-provided ``Scalers`` to use when scaling submodels.\n", + "\n", + "## Step 4: Apply Scaling to Sub-Models\n", + "\n", + "First, lets look at how to scale the property and reaction sub-models. As these are modular packages, we do not know what variables and constraints may be in them, so we cannot (and should not) scale any of these directly. However, we can (hopefully) assume that there are ``Scalers`` available for these sub-models, either through default ``Scalers`` associated with the property packages or provided by the user. Thus, what we want to do here is to call the variable and constraint scaling routines from the ``Scaler`` associated with each sub-model, which we can do using the ``call_submodel_scaler_method`` method from the ``CustomScalerBase`` class.\n", + "\n", + "The cell below prints the doc-string for the ``call_submodel_scaler_method`` method so we can see what the expected arguments are." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Help on function call_submodel_scaler_method in module idaes.core.scaling.custom_scaler_base:\n", + "\n", + "call_submodel_scaler_method(self, submodel, method: str, submodel_scalers: pyomo.common.collections.component_map.ComponentMap = None, overwrite: bool = False)\n", + " Call scaling method for submodel.\n", + " \n", + " Scaler for submodel is taken from submodel_scalers if present, otherwise the\n", + " default scaler for the submodel is used.\n", + " \n", + " Args:\n", + " submodel: submodel to be scaled\n", + " submodel_scalers: user provided ComponentMap of Scalers to use for submodels\n", + " method: name of method to call from submodel (as string)\n", + " overwrite: whether to overwrite existing scaling factors\n", + " \n", + " Returns:\n", + " None\n", + "\n" + ] + } + ], + "source": [ + "help(CustomScalerBase.call_submodel_scaler_method)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that ``call_submodel_scaler_method`` takes 4 arguments:\n", + "\n", + "1. ``submodel`` is the submodel we want to scale. \n", + "2. The ``submodel_scalers`` argument should be passed through from the ``variable_scaling_routine`` or ``constraint_scaling_routine`` method.\n", + "3. The name of the method we want to call from the ``Scaler`` when we get it - this will normally be either ``variable_scaling_routine`` (if we are scaling variables) or ``constraint_scaling_routine`` (if we are doing constraints).\n", + "4. The ``overwrite`` argument should also be passed through from the ``variable_scaling_routine`` or ``constraint_scaling_routine`` method.\n", + "\n", + "For the Equilibrium Reactor, we have three submodels to scale; inlet state, outlet state and reactions. As mentioned in the introduction, when developing scaling routines always start with the things you have the most information about. In this case, we likely know the most about the inlet state; either it is a defined feed state (like in our test case) or we have some idea of the state (and scaling) from propagating values from an upstream operation. So, to apply variable scaling to the inlet state we would do the following:\n", + "\n", + "```python\n", + "self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + ")\n", + "```\n", + "\n", + "Once we have an idea of scaling for the inlet we can use that information to try to estimate scaling for the outlet state. The default assumption is that the scaling of the outlet will be similar to that of the inlet, so the easy path is to copy scaling from the inlet state to the outlet. However, we know that something must change between inlet and outlet (as otherwise this unit operation is doing nothing) so we should always stop and think about whether we can try to estimate these changes. For example, in a pressure changer we know, or be able to estimate, the pressure change across the unit and thus be able to change the scaling of pressure between the inlet and outlet. However, keep in mind that over-scaling can make things worse so be judicious when deciding whether to adjust scaling based on estimates.\n", + "\n", + "In regards to this, Equilibrium Reactors are one of the more challenging units to scale, as it is very hard to know what the outlet flows and concentrations will be without knowing what the reactions are (and even if you know the reactions it is often hard to know the equilibrium state). In most cases, we have no reliable way to estimate the outlet flowrate and concentrations, so this is best left to the user to provide. In the case of temperature and pressure, whilst we may expect these to change but any change will generally be 1-2 orders of magnitude less than the inlet state and thus the overall scale of these will likely remain similar. Thus, for the Equilibrium Reactor it is probably sufficient to just scale the outlet state based on the inlet state.\n", + "\n", + "The ``CustomScalerBase`` class has a method for propagating scaling factors for state variables from one state to another called ``propagate_state_scaling`` as see below." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Help on function propagate_state_scaling in module idaes.core.scaling.custom_scaler_base:\n", + "\n", + "propagate_state_scaling(self, target_state, source_state, overwrite: bool = False)\n", + " Propagate scaling of state variables from one StateBlock to another.\n", + " \n", + " Indexing of target and source StateBlocks must match.\n", + " \n", + " Args:\n", + " target_state: StateBlock to set scaling factors on\n", + " source_state: StateBlock to use as source for scaling factors\n", + " overwrite: whether to overwrite existing scaling factors\n", + " \n", + " Returns:\n", + " None\n", + "\n" + ] + } + ], + "source": [ + "help(CustomScalerBase.propagate_state_scaling)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here we can see that ``propagate_state_scaling`` takes three arguments; the ``StateBlock`` we want to apply scaling to, the ``StateBlock`` we want to use as the source for the scaling factors, and the ``overwrite`` argument. Thus, we can propagate scaling from the inlet state to the outlet state as shown below.\n", + "\n", + "```python\n", + "self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + ")\n", + "```\n", + "\n", + "This only propagates scaling factors for the state variables, however, so we should then call the ``Scaler`` for the outlet state block to scale any remaining variables and constraints (which will hopefully make use of the scaling factors for the state variables we just propagated).\n", + "\n", + "We can then move on to scaling the ``ReactionBlock``. ``ReactionBlocks`` are slightly unusual in that they rely heavily on the state variables defined in a separate ``StateBlock`` - in this case the outlet state block. As we just applied a ``Scaler`` to the outlet state block, we can assume that all of the necessary variables have been scaled so all we need to do now is call a ``Scaler`` for the ``ReactionBlock``.\n", + "\n", + "All of this is shown in the cell below." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can then take a similar approach for the constraint scaling routine as shown below. Note that there is no need for a propagation step here as the residual of a constraint is derived from the value of the variables (which we handled in the variable scaling step)." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets do a quick check to see if our new scaler works and how it has affected the model scaling. The cell below creates a function that builds a new instance of the model (to avoid contamination from previous model runs then creates an instance of our new scaler and applies it to the model. We then solve the scaled model (adding scaling changes constraint residuals so we want to solve to the scaled state). Finally, the function prints a report of the scaling factors in the model and calls the ``report_numerical_issues`` method from the Diagnostics Toolbox." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import check_optimal_termination, TransformationFactory\n", + "\n", + "from idaes.core.scaling import report_scaling_factors\n", + "\n", + "\n", + "def check_scaling(tee=False):\n", + " # Build new instance of model\n", + " m = build_model()\n", + "\n", + " # Apply scaler to model\n", + " scaler = EquilibriumReactorScaler()\n", + " scaler.scale_model(m.fs.equil)\n", + "\n", + " # Solve scaled model\n", + " results = solver.solve(m, tee=tee)\n", + " if check_optimal_termination(results):\n", + " print(\"\\nModel Solved\\n\")\n", + " else:\n", + " print(\"\\nModel Failed to Converge!\\n\")\n", + "\n", + " # Print report of scaling factors\n", + " report_scaling_factors(m.fs.equil, descend_into=True)\n", + "\n", + " # Show numerical issues report\n", + " sm = TransformationFactory(\"core.scale_model\").create_using(m, rename=False)\n", + "\n", + " dt = DiagnosticsToolbox(model=sm.fs.equil)\n", + " dt.report_numerical_issues()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets run the ``check_scaling`` function and see how the model scaling has changed." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.enthalpy_balances[0.0] None\n", + "fs.equil.control_volume.pressure_balance[0.0] None\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.022E+09\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "5 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "From the scaling factor report, we can see that by calling the submodel scalers we have already scaled many of the variables in our problem, as well as three of the constraints. If we look at the \"Scaled Value\" column for the variables, we can also see that most of the scaled values are close to 1 (the few outliers might be things we want to look into more later on).\n", + "\n", + "From the numerical diagnostics, we can see that the Jacobian condition number has decreased by a few orders of magnitude, although it is still large, whilst we still have a number of potential issues with individual variables and constraints. All up though, this appears to be a step in the right direction." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5: Apply Variable Scaling\n", + "\n", + "Next, we need to look at scaling the variables and constraints that make up the unit model itself. From a conceptual standpoint, it is generally easiest to start with the variables as we generally have at least some idea of the magnitude of these.\n", + "\n", + "For the equilibrium reactor, we have the following variables we need to scale:\n", + "\n", + "1. Rate-based reaction extent and generation terms\n", + "2. Equilibrium-based reaction extent and generation terms\n", + "3. Inherent reaction extent and generation terms\n", + "4. Phase equilibrium generation terms\n", + "5. Energy balance heat term\n", + "6. Energy balance heats of reaction\n", + "7. Pressure drop\n", + "\n", + "Many of these are hard to know a priori - anything related to a reaction is very hard to know without knowing the reaction behavior. Considering that the equilibrium reactor is modular, we have little to no way of knowing these in the general case (and even in the specific test case it is hard enough). We can assume that the reaction package will scale all of its variables (i.e., rate and equilibrium constants, and reaction rates), however it is hard to project these to unit model scaling.\n", + "\n", + "For a CSTR we can say that ``extent = volume*rate`` and thus estimate scaling, but this does not work for equilibrium systems where 1) volume is undefined, 2) reaction rate at the outlet state is being driven to zero to satisfy equilibrium, and 3) extent is solved implicitly to satisfy the need for reaction rate to equal zero.\n", + "\n", + "Considering that a bad guess is often worse than no guess, we will not scale these right now - it is important to remember that our goal is to improve the overall scaling so if we do not know how to scale something it is generally best to leave it unscaled. We might come back to these later if necessary, but for now we will leave these either for the user to provide based on knowledge of their system, or for automated fill-in using some autoscaler.\n", + "\n", + "For the heat and deltaP terms, these are dependent on extensive variables in each case study and we have no way of knowing their exact values. However, we can probably take a good guess at order-of-magnitude using engineering knowledge; heat duties are generally approximately one order of magnitude smaller than the enthalpy flows,\n", + "and pressure drops are generally on the order of 0.1 bar.\n", + "\n", + "To apply scaling for the pressure drop term, we can make use of the ``scale_variable_by_units`` method in ``CustomScalerBase``. This method looks up the units of measurement for the variable, and then loops in the class attribute ``UNIT_SCALING_FACTORS`` dictionary to find an equivalent unit for the quantity of interest and an associated scaling factor. If a scaling factor is found, it is converted as necessary; e.g., in this case pressure is defined in ``Pa`` but we can set the default scaling factor in ``bar`` and it will be converted as appropriate. The code required to do this is below.\n", + "\n", + "```python\n", + "UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + "}\n", + "\n", + "def variable_scaling_routine(*args, **kwargs):\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t],\n", + " overwrite=overwrite\n", + " )\n", + "```\n", + "\n", + "There are a few things to note here:\n", + "\n", + "1. As we expect the pressure drop to be on the order of 0.1 bar, we need to set a scaling factor of 10 for quantities with units of pressure. Also note that the key ``\"Pressure Change\"`` is for documentation purposes only and is not actually used by the code (but must be there). \n", + "\n", + "
\n", + "NOTE We cannot distinguish between different quantities with the same apparent units (e.g., we cannot distinguish between an absolute pressure and a pressure change).\n", + "
\n", + "\n", + "2. Note that scaling is applied to elements of indexed components and not to the indexed component as a whole, and thus we need to use a ``for`` loop to iterate over the time index. This is done to force modelers to consider how the scaling of a variable or constraint will vary over the indexed domain, and try to discourage automatically setting a single scaling factor for all points.\n", + "3. Pressure change is a configuration argument in our unit model, and thus may not be present in all cases. Therefore, we need the ``hasattr`` check to see if we need to scale ``deltaP`` or not.\n", + "\n", + "For the case of the heat duty, we want to scale based on the incoming enthalpy flow which means we first need to get the expected magnitude of the enthalpy flow. For that, we can use the ``get_expression_nominal_values`` method in ``CustomScalerBase`` which uses an expression walker to go through an expression to return a list of the expected magnitude (or nominal value) of all additive terms in the expression based on the scaling factors for the variables involved.\n", + "\n", + "We can get an expression for the enthalpy flow term using the ``get_enthalpy_flow_terms`` method from the associated ``StateBlock``. We should assume this expression might contain multiple terms, so we should sum all the values returned to get the overall magnitude of the enthalpy flow term. Once we have this, we can then get the scaling factor for the heat duty by ``sf = abs(1/(0.1*enthalpy_flow))`` - note that the tools insist on scaling factors being positive (for sanity) and thus we need the absolute value here in case enthalpy flow is negative (which is not uncommon for enthalpy). The code to do this is shown below.\n", + "\n", + "```python\n", + "if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[t].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is general one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(model.control_volume.heat[t], abs(1 / (0.1 * h_in)))\n", + "```\n", + "\n", + "Putting all of this together results in the code below for our ``EquilibriumReactorScaler`` class." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import units\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " # =======================================================================================\n", + " # New Code\n", + " UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + " }\n", + " # =======================================================================================\n", + "\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # =======================================================================================\n", + " # New Code\n", + "\n", + " # Pressure drop - optional\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t], overwrite=overwrite\n", + " )\n", + "\n", + " # Heat transfer - optional\n", + " # Scale heat based on enthalpy flow entering reactor\n", + " if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[\n", + " t\n", + " ].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is generally one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(\n", + " model.control_volume.heat[t], abs(1 / (0.1 * h_in))\n", + " )\n", + " # =======================================================================================\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once again, lets run the ``check_scaling`` function and see how we are going." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] 4.794E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] 1.000E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.enthalpy_balances[0.0] None\n", + "fs.equil.control_volume.pressure_balance[0.0] None\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.022E+09\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "5 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Our updates have resulted in scaling factors for ``heat`` and ``deltaP`` appearing in the scaling report which is good, but comparing the diagnostics from the previous step we can see that the Jacobian condition number has not changed. Does this mean we did something wrong?\n", + "\n", + "The answer is no - when we add a scaling factor to a variable, wherever that variable appears in a constraint it is replaced with ``sf*v_scaled``. Given that ``v_scaled = v/sf``, this means that for variables which only appear linearly in constraints then the partial derivative with respect to the scaled variable does not change either; thus the Jacobian is unaffected by scaling only the linear variables. In the case of this example, it turns out that almost all the variables appear linearly and thus we see no change in the Jacobian condition number.\n", + "\n", + "
\n", + "NOTE It is important to note that partial scaling of a model (e.g., variables only) can often appear worse than that of the unscaled model. Generally, it is best to wait until you have scaled both variables and constraints to make a decision on whether your attempts at scaling have made the problem better or worse, and you should not be discouraged if things look worse while in an intermediate state.\n", + "
\n", + "\n", + "\n", + "## Step 6: Apply Constraint Scaling\n", + "\n", + "Now that we have scaled all the variables that we can (for now at least), we can move on to scaling constraints. The advantage of scaling all the variables first means that now we have an idea of the expected magnitude for all terms in the constraints which we can use to estimate scaling factors. For the Equilibrium reactor model, we need to scale all the constraints in the control volume, as well as the unit level constraint equating all reaction rates to zero.\n", + "\n", + "There are many approaches to estimating scaling for constraints, and different approaches are better suited to certain situations. ``CustomScalerBase`` contains a ``scale_constraint_by_nominal_value`` method which can be used to automatically implement a number of common approaches to save you the effort of having to manually implement these yourself. As of writing, the approaches (or schemes) supported are:\n", + "\n", + "1. ``ConstraintScalingScheme.inverseMaximum`` - scale the constraint based on the term with the largest absolute expected magnitude. This is scheme is useful for cases where most terms have similar magnitudes and is a good initial point to start.\n", + "2. ``ConstraintScalingScheme.inverseMinimum`` - scale the constraint based on the term with the smallest absolute expected magnitude. This scheme is similar to the inverse maximum scheme and is useful for cases where you have a constraint with a number of smaller terms mixed with a few larger terms, or cases where the smaller term is expected to be most significant. This scheme should be used carefully however as it can result in large scaling factors making convergence of larger terms difficult.\n", + "3. ``ConstraintScalingScheme.harmonicMean`` - scale the constraint using the harmonic mean of the absolute expected magnitude of all terms (``sf = sum(1/abs(nominal value))``). This scheme is most useful when you have a constraint with terms with a mix of expected magnitudes where you need to find a balance between the large and small terms.\n", + "4. ``ConstraintScalingScheme.inverseSum`` - scale the constraint using the sum of the absolute expected magnitudes of all terms. Situationally useful for cases with terms of mixed magnitudes.\n", + "5. ``ConstraintScalingScheme.inverseRSS`` - scale the constraint using the root sum of squares of the absolute expected magnitudes of all terms. Situationally useful for cases with terms of mixed magnitudes.\n", + "\n", + "``CustomScalerBase`` also contains a ``scale_constraint_by_nominal_derivative_norm`` method that can scale a constraint based on an estimate of the Jacobian norm associated with that constraint which can be useful for cases where you want to focus on the Jacobian scaling.\n", + "\n", + "
\n", + "NOTE The solver you intend to use may impact which approach provides the best scaling for a given model. For example, IPOPT has very good internal Jacobian scaling (when using the `gradient-based` scaling option), and thus benefits the most from focusing on scaling the constraint residual magnitudes as opposed to the Jacobian.\n", + "
\n", + "\n", + "For this workshop, we will start by just using ``ConstraintScalingScheme.inverseMaximum`` to get a starting point and to see if further scaling is required. We can apply this scheme to scale all the constraints in the control volume using the code below.\n", + "\n", + "```python\n", + "for c in model.control_volume.component_data_objects(\n", + " Constraint, descend_into=False\n", + "):\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + "```\n", + "\n", + "Adding this and a similar approach to scale the unit level constraint gives us the code below for our ``EquilibriumreactorScaler`` class." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.scaling import ConstraintScalingScheme\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + " }\n", + "\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # Pressure drop - optional\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t], overwrite=overwrite\n", + " )\n", + "\n", + " # Heat transfer - optional\n", + " # Scale heat based on enthalpy flow entering reactor\n", + " if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[\n", + " t\n", + " ].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is generally one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(\n", + " model.control_volume.heat[t], abs(1 / (0.1 * h_in))\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # =======================================================================================\n", + " # New Code\n", + " # Scale control volume constraints\n", + " for c in model.control_volume.component_data_objects(\n", + " Constraint, descend_into=False\n", + " ):\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # Scale unit level constraints\n", + " if hasattr(model, \"rate_reaction_constraint\"):\n", + " for c in model.rate_reaction_constraint.values():\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + " # =======================================================================================" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once again, let us use the ``check_scaling`` function to see how our ``Scaler`` performs." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] 4.794E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] 1.000E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] 1.000E+02\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] 1.000E+00\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] 1.000E+01\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] 1.000E-02\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] 1.000E+00\n", + "fs.equil.control_volume.enthalpy_balances[0.0] 7.715E-08\n", + "fs.equil.control_volume.pressure_balance[0.0] 9.869E-06\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 7.182E+04\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "3 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 2 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": { + "tags": [ + "testing" + ] + }, + "outputs": [], + "source": [ + "# Build a new instance of the model for testing\n", + "test_model = build_model()\n", + "scaler = EquilibriumReactorScaler()\n", + "scaler.scale_model(test_model.fs.equil)\n", + "\n", + "# Check that the number of scaling factors assigned matches expectations\n", + "# We will assume they were set correctly\n", + "assert len(test_model.fs.equil.scaling_factor) == 1\n", + "assert len(test_model.fs.equil.control_volume.scaling_factor) == 14\n", + "assert len(test_model.fs.equil.control_volume.properties_in[0].scaling_factor) == 8\n", + "assert len(test_model.fs.equil.control_volume.properties_out[0].scaling_factor) == 9\n", + "assert len(test_model.fs.equil.control_volume.reactions[0].scaling_factor) == 4" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "From the results of ``check_scaling`` we can see that we now have scaling factors for almost all the variables and constraints in the model (the only exceptions being the reaction related variables we left unscaled earlier). More importantly, we can see that the Jacobian condition number is now down to ``7.2E4`` from the original ``1.5E12`` which is an impressive improvement (and for not a lot of effort on our part). We can also see that the numerical diagnostics are no longer reporting any variables or constraints with extreme Jacobians (there are 2 individual entries that are a bit large, but it appears they are not having a big impact on the condition number).\n", + "\n", + "We do see that there are a number of variables with values close to ``0`` which we should be wary of, but in this case it is due to the case study we are using. Here we are using an equilibrium reactor to drive a rate-based reaction to completion, which necessitates that at least one reactant have a concentration of zero as well as the reaction rate for all reactions. Thus, for this case these are unavoidable. As mentioned earlier, we really should be asking whether an Equilibrium Reactor is well suited for the reaction model we have here, and a Stoichiometric Reactor would probably have been a better choice (or a better reaction package which use reversible reactions with equilibrium).\n", + "\n", + "\n", + "## Step 7: Review Scaling Routine\n", + "\n", + "We now have a new ``Scaler`` for an equilibrium reactor that uses the modular nature of IDAES to implement a general purpose scaling routine (or so we hope at least). So, does this mean we are done?\n", + "\n", + "No, or not yet at least.\n", + "\n", + "We should always take a step back and ask ourselves if what we have is good enough and see if we can see any areas where we might be able to do better, or places where edge cases might exist. As a starting point, let us first see how we compare to an autoscaling routine using the model Jacobian. We can use the ``AutoScaler.scale_model`` method for this as shown below." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: model contains export suffix 'scaling_factor' that contains 10\n", + "component keys that are not exported as part of the NL file. Skipping.\n", + "\n", + "Model Solved\n", + "\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.863E+06\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "4 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 2 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 7 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "from idaes.core.scaling import AutoScaler\n", + "\n", + "m = build_model()\n", + "\n", + "scaler = EquilibriumReactorScaler()\n", + "autoscaler = AutoScaler()\n", + "\n", + "autoscaler.scale_model(m)\n", + "\n", + "solver = get_solver(\n", + " \"ipopt_v2\", writer_config={\"scale_model\": True, \"linear_presolve\": True}\n", + ")\n", + "results = solver.solve(m)\n", + "\n", + "if check_optimal_termination(results):\n", + " print(\"\\nModel Solved\\n\")\n", + "else:\n", + " print(\"\\nModel Failed to Converge!\\n\")\n", + "\n", + "sm = TransformationFactory(\"core.scale_model\").create_using(m, rename=False)\n", + "\n", + "dt = DiagnosticsToolbox(model=sm.fs.equil)\n", + "dt.report_numerical_issues()" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": { + "tags": [ + "testing" + ] + }, + "outputs": [], + "source": [ + "# Test to ensure autoscaled model solved\n", + "assert check_optimal_termination(results)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that our ``EquilibriumReactorScaling`` routine actually results in a lower Jacobian condition number than the ``AutoScaler`` approach, so that is a sign we are doing things right. It is not unusual to see that we can get better scaling with a manual, magnitude based approach than an autoscaler as the autoscaler focuses solely on the Jacobian and thus often over-scales the problem.\n", + "\n", + "However, we might be able to do better by using other constraint scaling schemes, but before we start experimenting we should stop and think about what sort of scaling might make sense for each constraint. We should always also keep in the back of our minds whether additional work is worth the effort, and if we risk over-tuning the scaling for the specific property package we have.\n", + "\n", + "Fortunately, the model in this example is fairly simple and we do not have too many constraints to consider. Firstly, we have the unit-level constraint that says that `rate_reaction == 0` for all rate-based reactions. When considering scaling of a constraint we should ignore any 0 terms, thus this constraint has only 1 term and so we should scale based on this. If we use the ``scale_constraint_by_nominal_value`` method for this it will ignore the zero for us, the scheme used does not actually matter as there is only one term to consider.\n", + "\n", + "Next, we have the balance equations which all have the form `0 == In - Out + Gen` - note the equilibrium reactor does not support dynamics so we don't need to think about that. Generation terms can vary a lot, but we basically have two possible cases:\n", + "\n", + "1. one term is negligible compared to the other 2, so we should scale based on one of the significant\n", + "terms, or\n", + "2. all three terms are of similar significance (e.g., inlet and gen are of similar scale and outlet\n", + "is ~inletx2). Here we could scale based on the harmonic mean, by the maximum term is probably not bad either.\n", + "\n", + "So, in short the maximum magnitude is probably the best general-purpose scale for these constraints.\n", + "\n", + "Finally, we have stoichiometric constraints with the form `G[j, r] == n[j, r]*X[r]` where ``G`` is generation, ``X`` is extent and ``n`` is the stoichiometric coefficient (i.e., a constant) - these are simple ``A=B`` constraints, so scaling by maximum magnitude is equivalent to other methods (as there are only two terms which will take the same value, all schemes will give the same result in the end).\n", + "\n", + "So, for the equilibrium reactor at least, we are probably best leaving things as they are.\n", + "\n", + "However, there is one important test left. The whole purpose of a scaling routine is to allow us to perturb the model and solve it at the new state so we should test to confirm that our new ``Scaler`` has improved the performance of our solver when solving for the perturbed state we tried earlier. This also lets us see how the new ``Scaler`` will look for a user trying to apply the tool, which we can see below." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Ipopt 3.13.2: linear_solver=ma57\n", + "max_iter=200\n", + "nlp_scaling_method=gradient-based\n", + "tol=1e-06\n", + "\n", + "\n", + "******************************************************************************\n", + "This program contains Ipopt, a library for large-scale nonlinear optimization.\n", + " Ipopt is released as open source code under the Eclipse Public License (EPL).\n", + " For more information visit http://projects.coin-or.org/Ipopt\n", + "\n", + "This version of Ipopt was compiled from source code available at\n", + " https://github.com/IDAES/Ipopt as part of the Institute for the Design of\n", + " Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE\n", + " Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.\n", + "\n", + "This version of Ipopt was compiled using HSL, a collection of Fortran codes\n", + " for large-scale scientific computation. All technical papers, sales and\n", + " publicity material resulting from use of the HSL codes within IPOPT must\n", + " contain the following acknowledgement:\n", + " HSL, a collection of Fortran codes for large-scale scientific\n", + " computation. See http://www.hsl.rl.ac.uk.\n", + "******************************************************************************\n", + "\n", + "This is Ipopt version 3.13.2, running with linear solver ma57.\n", + "\n", + "Number of nonzeros in equality constraint Jacobian...: 21\n", + "Number of nonzeros in inequality constraint Jacobian.: 0\n", + "Number of nonzeros in Lagrangian Hessian.............: 9\n", + "\n", + "Total number of variables............................: 8\n", + " variables with only lower bounds: 5\n", + " variables with lower and upper bounds: 1\n", + " variables with only upper bounds: 0\n", + "Total number of equality constraints.................: 8\n", + "Total number of inequality constraints...............: 0\n", + " inequality constraints with only lower bounds: 0\n", + " inequality constraints with lower and upper bounds: 0\n", + " inequality constraints with only upper bounds: 0\n", + "\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 0 0.0000000e+00 5.53e+02 1.00e+00 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0\n", + "Reallocating memory for MA57: lfact (247)\n", + " 1 0.0000000e+00 5.53e+02 1.20e+00 -1.0 9.95e+02 - 2.00e-05 1.96e-05h 1\n", + " 2 0.0000000e+00 5.53e+02 1.20e+00 -1.0 9.57e+02 - 2.06e-05 2.00e-05h 1\n", + " 3 0.0000000e+00 5.53e+02 7.36e+01 -1.0 9.25e+02 - 4.36e-04 4.06e-05h 1\n", + " 4 0.0000000e+00 5.53e+02 3.34e+05 -1.0 8.55e+02 - 2.41e-04 1.21e-03f 1\n", + " 5 0.0000000e+00 5.40e+02 6.59e+03 -1.0 9.98e+01 - 2.25e-04 2.34e-02f 1\n", + " 6 0.0000000e+00 5.24e+02 1.11e+08 -1.0 9.74e+01 - 2.54e-02 2.84e-02f 1\n", + " 7 0.0000000e+00 2.36e+02 2.03e+06 -1.0 9.47e+01 - 7.09e-02 5.49e-01h 1\n", + " 8 0.0000000e+00 8.62e+01 6.37e+10 -1.0 4.27e+01 - 8.23e-03 6.35e-01h 1\n", + " 9 0.0000000e+00 1.96e+00 4.93e+10 -1.0 1.56e+01 - 1.00e+00 1.00e+00h 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 10 0.0000000e+00 2.05e-04 2.15e+09 -1.0 3.70e-02 - 1.00e+00 1.00e+00h 1\n", + " 11 0.0000000e+00 6.28e-15 2.56e+05 -1.0 2.05e-04 - 1.00e+00 1.00e+00h 1\n", + "\n", + "Number of Iterations....: 11\n", + "\n", + " (scaled) (unscaled)\n", + "Objective...............: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Dual infeasibility......: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Constraint violation....: 6.2780236478193237e-15 6.2780236478193237e-15\n", + "Complementarity.........: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Overall NLP error.......: 6.2780236478193237e-15 6.2780236478193237e-15\n", + "\n", + "\n", + "Number of objective function evaluations = 12\n", + "Number of objective gradient evaluations = 12\n", + "Number of equality constraint evaluations = 12\n", + "Number of inequality constraint evaluations = 0\n", + "Number of equality constraint Jacobian evaluations = 12\n", + "Number of inequality constraint Jacobian evaluations = 0\n", + "Number of Lagrangian Hessian evaluations = 11\n", + "Total CPU secs in IPOPT (w/o function evaluations) = 0.000\n", + "Total CPU secs in NLP function evaluations = 0.000\n", + "\n", + "EXIT: Optimal Solution Found.\n" + ] + } + ], + "source": [ + "m = build_model()\n", + "\n", + "scaler = EquilibriumReactorScaler()\n", + "scaler.scale_model(m.fs.equil)\n", + "\n", + "perturb_model(m)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that by applying our new ``EquilibriumReactorScaler`` we are now able to use IPOPT to solve for the perturbation, and that it reaches an optimal solution in 11 iterations. Looking at the solver logs we can see that the solver step lengths (``alpha_du`` and ``alpha_pr``) are rather small for the first iterations but the number of line searches (``ls``) is 1 for all iterations. This indicates that IPOPT is pushing up against some bound or constraint and cannot make full steps, but in this case it is due to the fact that to achieve equilibrium for an irreversible reaction at least one concentration must be driven to zero (and is why an EquibriumReactor is probably not a good choice for this test case). However, the fact that our ``Scaler`` let us solve for this challenging test case is probably a good sign.\n", + "\n", + "\n", + "## Step 8: Finishing Up\n", + "\n", + "Ideally, we would have more than one test case to apply our ``Scaler`` to put it through its paces and ensure it is robust across a wide range of conditions. However, for the purposes of this workshop we will move on.\n", + "\n", + "Once you are satisfied that your ``Scaler`` is ready, you can start applying it to actual problems of interest. For those modelers developing new unit and property models, you should assign your new ``Scaler`` as the default scaler for that unit model. You can do this by setting the ``default_scaler`` attribute on your model to point to the new ``Scaler`` as shown below.\n", + "\n", + "```python\n", + "@declare_process_block_class(\"EquilibriumReactor\")\n", + "class EquilibriumReactorData(UnitModelBlockData):\n", + " \"\"\"\n", + " Standard Equilibrium Reactor Unit Model Class\n", + " \"\"\"\n", + "\n", + " # Setting the default_scaler attribute\n", + " default_scaler = EquilibriumReactorScaler\n", + "```\n", + "\n", + "With that, we have finished this workshop on developing ``Scaler`` classes. Hopefully you now know enough to begin writing ``Scalers`` for your own models, and have gained some insight into how to think about developing scaling routines and the tools available to help you." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": { + "tags": [ + "testing" + ] + }, + "outputs": [], + "source": [ + "# Test that scaled model re-solves\n", + "results = solver.solve(m)\n", + "assert check_optimal_termination(results)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "celltoolbar": "Tags", + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 3 +} \ No newline at end of file diff --git a/idaes_examples/notebooks/docs/scaling/scaler_workshop_usr.ipynb b/idaes_examples/notebooks/docs/scaling/scaler_workshop_usr.ipynb new file mode 100644 index 00000000..a7ba87bb --- /dev/null +++ b/idaes_examples/notebooks/docs/scaling/scaler_workshop_usr.ipynb @@ -0,0 +1,1912 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "tags": [ + "header", + "hide-cell" + ] + }, + "outputs": [], + "source": [ + "###############################################################################\n", + "# The Institute for the Design of Advanced Energy Systems Integrated Platform\n", + "# Framework (IDAES IP) was produced under the DOE Institute for the\n", + "# Design of Advanced Energy Systems (IDAES).\n", + "#\n", + "# Copyright (c) 2018-2023 by the software owners: The Regents of the\n", + "# University of California, through Lawrence Berkeley National Laboratory,\n", + "# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon\n", + "# University, West Virginia University Research Corporation, et al.\n", + "# All rights reserved. Please see the files COPYRIGHT.md and LICENSE.md\n", + "# for full copyright and license information.\n", + "###############################################################################" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# How to Create Scaler Objects in IDAES\n", + "\n", + "Author: Andrew Lee\n", + "Maintainer: Doug Allan\n", + "Updated: 2024-10-24\n", + "\n", + "## Introduction\n", + "\n", + "
\n", + "NOTE All the suggestions in this introduction should be viewed as \"rules-of-thumb\" and not taken as absolute guidance. There are many cases where alternative approaches may give as-good or better results and you should always consider the meaning of the scaling factors you are applying and how they affect the solver's behavior. \n", + "
\n", + "\n", + "Solving general non-linear problems has always been challenging, and is highly dependent on how well scaled the model is. In many cases, as much time (or more) is spent trying to improve the model formulation and scaling as was spent writing the original model. To assist molders with this task, IDAES has implemented a Scaling Toolbox which contains a number of useful tools for common scaling techniques as well as a standard interface and form for how to write scaling routines.\n", + "\n", + "The goal of this workshop is to take you through the process of writing a general-purpose, modular scaling routine for an equilibrium reactor example. By the end of this exercise you should:\n", + "\n", + "* understand the ``CustomScalerBase`` class and how to apply the tools it contains,\n", + "* understand how to use ``CustomScalerBase`` to set up a modular scaling routine for a model,\n", + "* understand how to use the Diagnostics Toolbox to check for scaling issues in a model.\n", + "\n", + "## How to Write a Scaling Routine\n", + "\n", + "
The golden rule when developing a scaling routine to a model is to always think about what you are doing and why. Bad scaling is often worse than no scaling at all, so assigning arbitrary scaling factors should be avoided. Always start by taking the time to look over the model you want to scale and understand what variables and constraints are present. For variables, you should ask yourself what the expected range of magnitudes will be; assigning an arbitrary default value should be avoided. For constraints you should ask yourself what the expected magnitude of each additive term will be, how much these vary from each other, and which term is likely to be most significant in terms of variation (partial derivatives).
\n", + "\n", + "
\n", + "NOTE Different solvers behave in different ways, and you may find cases where tuning scaling for one solver results in worse performance for another.\n", + " \n", + "You should always consider the end-goal when writing a Scaler; if you are writing a routine for a specific application and solver then you may wish to tune the scaling factors for best performance, however if you are writing a general-purpose Scaler then you should aim for scaling that will work for a wide range of conditions and solver.\n", + "
\n", + "\n", + "Below are some general suggestions for developing scaling routines.\n", + " \n", + "* Order of magnitude estimates are generally good enough (and often better than exact values).\n", + "* Start with what you know the most about, and work out from there.\n", + "* If in doubt, start by scaling variables first, and then scale constraints based on the variable scaling.\n", + "* Be judicious when applying scaling factors for things you are uncertain about. If in doubt, leave a component unscaled and see what the model diagnostics have to say.\n", + "* Make use of the modular nature of IDAES when writing scaling routines. A unit model developer might not know the expected magnitude of the thermophysical properties they get from a property package, but there should be a scaling routine for the property package that they can call to provide these.\n", + "\n", + "
\n", + "NOTE When dealing with systems of partial differential algebraic equations (PDAEs), such as dynamic systems or those with spatial variation, it is important to consider how scaling may change across the discretized domain. In many of these types of models, you will find significant changes in scale across a small portion of the domain; for example a dynamic model of a step disturbance will show an initial equilibrium state followed by a rapid change in system conditions until a new equilibrium is established. To complicate things further, the location of this ramp can often move significantly with minor changes in system conditions, thus you should not presume that the ramp will remain in the same place.\n", + " \n", + "As a general rule, for scaling PDAE systems with significant changes, you should focus on finding a set of scaling factors that is suitable for the ramp region as this is the part of the model which will be hardest to solve.\n", + "
\n", + "\n", + "### IDAES Scaling Interface and Toolbox\n", + "\n", + "IDAES uses a class-based interface for defining scaling routines, where model developers can create ``Scaler`` objects which define a scaling routine suitable for a type of model or specific application. All models (both those in the IDAES model libraries and user-developed models) should have one or more ``Scaler`` classes defined for them that can be used to apply scaling routines to the model. To assist end-users in identifying a suitable ``Scaler`` for a model, all IDAES models have a ``default_scaler`` attribute which can be set to point to a ``Scaler`` object suitable for that model. Model developers should endeavor to create a reliable, general-purpose ``Scaler`` for each model they create and assign this as the default ``Scaler``. We will demonstrate how to do this at the end of this workshop.\n", + "\n", + "\n", + "## Step 1: Set Up Test Case(s)\n", + "\n", + "Whilst it is possible to develop a scaling routine by looking only at the model code and the resulting variables and constraints, in order to test it we will need one or more test cases to run. These test cases are important for both checking the that ``Scaler`` code runs as expected, and that it also improves the scaling of the model. The more test cases you can check against, the more confident you can be that the ``Scaler`` you have written is suitable for a wide range of applications.\n", + "\n", + "For this example we will develop a general purpose ``Scaler`` for the ``EquilibriumReactor`` model from the core IDAES model library using the saponification property and reaction packages as a test case. The code below imports the necessary packages and creates a function that will build and initialize our test case." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import ConcreteModel, Constraint, units, Var\n", + "\n", + "from idaes.core import FlowsheetBlock\n", + "from idaes.models.unit_models.equilibrium_reactor import (\n", + " EquilibriumReactor,\n", + ")\n", + "from idaes.models.properties.examples.saponification_thermo import (\n", + " SaponificationParameterBlock,\n", + ")\n", + "from idaes.models.properties.examples.saponification_reactions import (\n", + " SaponificationReactionParameterBlock,\n", + ")\n", + "from idaes.core.solvers import get_solver\n", + "from idaes.core.initialization import BlockTriangularizationInitializer\n", + "from idaes.core.util import DiagnosticsToolbox\n", + "\n", + "\n", + "def build_model():\n", + " m = ConcreteModel()\n", + " m.fs = FlowsheetBlock(dynamic=False)\n", + "\n", + " m.fs.properties = SaponificationParameterBlock()\n", + " m.fs.reactions = SaponificationReactionParameterBlock(\n", + " property_package=m.fs.properties\n", + " )\n", + "\n", + " m.fs.equil = EquilibriumReactor(\n", + " property_package=m.fs.properties,\n", + " reaction_package=m.fs.reactions,\n", + " has_equilibrium_reactions=False,\n", + " has_heat_transfer=True,\n", + " has_heat_of_reaction=True,\n", + " has_pressure_change=True,\n", + " )\n", + "\n", + " m.fs.equil.inlet.flow_vol[0].fix(1.0e-03 * units.m**3 / units.s)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"H2O\"].fix(55388.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"NaOH\"].fix(100.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"EthylAcetate\"].fix(\n", + " 100.0 * units.mol / units.m**3\n", + " )\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"SodiumAcetate\"].fix(0.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"Ethanol\"].fix(0.0 * units.mol / units.m**3)\n", + "\n", + " m.fs.equil.inlet.temperature[0].fix(303.15 * units.K)\n", + " m.fs.equil.inlet.pressure[0].fix(101325.0 * units.Pa)\n", + "\n", + " m.fs.equil.heat_duty.fix(0 * units.W)\n", + " m.fs.equil.deltaP.fix(0 * units.Pa)\n", + "\n", + " initializer = BlockTriangularizationInitializer()\n", + " initializer.initialize(m.fs.equil)\n", + "\n", + " return m" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Before we move on to try to solve the model or develop a ``Scaler``, we should first check to make sure the model is well-posed and that there are not any structural issues that will prevent us from solving the model. The code below creates an instance of the IDAES Diagnostics Toolbox and runs the ``report_structural_issues`` method to ensure there are no warnings." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Activated Blocks: 5 (Deactivated: 0)\n", + " Free Variables in Activated Constraints: 16 (External: 0)\n", + " Free Variables with only lower bounds: 6\n", + " Free Variables with only upper bounds: 0\n", + " Free Variables with upper and lower bounds: 2\n", + " Fixed Variables in Activated Constraints: 10 (External: 0)\n", + " Activated Equality Constraints: 16 (Deactivated: 0)\n", + " Activated Inequality Constraints: 0 (Deactivated: 0)\n", + " Activated Objectives: 0 (Deactivated: 0)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "0 WARNINGS\n", + "\n", + " No warnings found!\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 Cautions\n", + "\n", + " Caution: 4 variables fixed to 0\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " Try to initialize/solve your model and then call report_numerical_issues()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "m = build_model()\n", + "\n", + "dt = DiagnosticsToolbox(model=m.fs.equil)\n", + "dt.report_structural_issues()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "# Make sure base model is constructed properly\n", + "dt.assert_no_structural_warnings()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "In order to fully test our new ``Scaler`` it is also useful to test how the model responds to perturbations in the state. In many ways, this is the real test of a scaling routine as it is easy to write something that gets good scaling for a known state (e.g., auto-scalers), but what we really need is a routine that can get good scaling across a range of conditions.\n", + "\n", + "The cell below creates a function that perturbs the state of our model significantly. Note that the volumetric flowrate has been increased by two orders of magnitude, the inlet concentrations have changed significantly, and we have also made a small change to the temperature." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.solvers import get_solver\n", + "\n", + "solver = get_solver(\n", + " \"ipopt_v2\", writer_config={\"scale_model\": True, \"linear_presolve\": True}\n", + ")\n", + "\n", + "\n", + "def perturb_model(m):\n", + " m.fs.equil.inlet.flow_vol.fix(1 * units.m**3 / units.s)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"NaOH\"].fix(200.0 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"EthylAcetate\"].fix(\n", + " 100.0 * units.mol / units.m**3\n", + " )\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"SodiumAcetate\"].fix(50 * units.mol / units.m**3)\n", + " m.fs.equil.inlet.conc_mol_comp[0, \"Ethanol\"].fix(1e-8 * units.mol / units.m**3)\n", + "\n", + " m.fs.equil.inlet.temperature.fix(320 * units.K)\n", + " solver.solve(m, tee=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets apply this perturbation to our example model and see how well it solves." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Ipopt 3.13.2: linear_solver=ma57\n", + "max_iter=200\n", + "nlp_scaling_method=gradient-based\n", + "tol=1e-06\n", + "\n", + "\n", + "******************************************************************************\n", + "This program contains Ipopt, a library for large-scale nonlinear optimization.\n", + " Ipopt is released as open source code under the Eclipse Public License (EPL).\n", + " For more information visit http://projects.coin-or.org/Ipopt\n", + "\n", + "This version of Ipopt was compiled from source code available at\n", + " https://github.com/IDAES/Ipopt as part of the Institute for the Design of\n", + " Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE\n", + " Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.\n", + "\n", + "This version of Ipopt was compiled using HSL, a collection of Fortran codes\n", + " for large-scale scientific computation. All technical papers, sales and\n", + " publicity material resulting from use of the HSL codes within IPOPT must\n", + " contain the following acknowledgement:\n", + " HSL, a collection of Fortran codes for large-scale scientific\n", + " computation. See http://www.hsl.rl.ac.uk.\n", + "******************************************************************************\n", + "\n", + "This is Ipopt version 3.13.2, running with linear solver ma57.\n", + "\n", + "Number of nonzeros in equality constraint Jacobian...: 21\n", + "Number of nonzeros in inequality constraint Jacobian.: 0\n", + "Number of nonzeros in Lagrangian Hessian.............: 9\n", + "\n", + "Total number of variables............................: 8\n", + " variables with only lower bounds: 5\n", + " variables with lower and upper bounds: 1\n", + " variables with only upper bounds: 0\n", + "Total number of equality constraints.................: 8\n", + "Total number of inequality constraints...............: 0\n", + " inequality constraints with only lower bounds: 0\n", + " inequality constraints with lower and upper bounds: 0\n", + " inequality constraints with only upper bounds: 0\n", + "\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 0 0.0000000e+00 9.09e+07 1.00e+00 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0\n", + "Reallocating memory for MA57: lfact (247)\n", + " 1r 0.0000000e+00 9.09e+07 9.99e+02 2.5 0.00e+00 - 0.00e+00 7.73e-09R 9\n", + " 2r 0.0000000e+00 8.42e+07 8.24e+03 2.5 7.20e+02 - 1.64e-02 2.59e-02f 1\n", + " 3r 0.0000000e+00 8.37e+07 7.72e+03 1.8 8.82e+04 - 5.56e-04 3.77e-05f 1\n", + " 4r 0.0000000e+00 3.76e+07 2.65e+04 1.8 1.13e+03 0.0 1.27e-01 1.63e-01f 1\n", + " 5r 0.0000000e+00 3.60e+07 2.30e+04 1.8 6.83e+01 1.3 7.53e-02 1.45e-01f 1\n", + " 6r 0.0000000e+00 4.17e+07 1.77e+04 1.8 2.10e+02 0.9 7.11e-02 1.47e-01f 1\n", + " 7r 0.0000000e+00 4.08e+07 1.75e+04 1.8 3.95e+02 0.4 2.35e-01 8.19e-03f 1\n", + " 8r 0.0000000e+00 3.13e+07 1.75e+04 1.8 1.12e+03 -0.1 3.57e-01 3.16e-02f 1\n", + " 9r 0.0000000e+00 7.77e+06 1.74e+04 1.8 1.00e+04 - 1.43e-02 9.06e-03f 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 10r 0.0000000e+00 7.36e+06 1.70e+04 1.8 2.14e+02 - 2.20e-01 2.38e-02f 1\n", + " 11r 0.0000000e+00 5.93e+06 1.67e+04 1.8 1.72e+02 - 7.89e-01 1.95e-01f 1\n", + " 12r 0.0000000e+00 1.54e+06 3.54e+04 1.8 1.06e+02 - 8.80e-01 7.41e-01f 1\n", + " 13r 0.0000000e+00 1.21e+06 2.79e+04 1.8 4.60e+00 - 1.00e+00 2.12e-01h 1\n", + " 14r 0.0000000e+00 3.31e+03 4.79e+01 1.8 2.39e+00 - 1.00e+00 1.00e+00f 1\n", + " 15r 0.0000000e+00 2.85e+03 7.72e+02 -0.2 2.01e+00 - 9.81e-01 9.09e-01f 1\n", + " 16r 0.0000000e+00 2.47e+03 6.60e+02 -0.2 9.87e-01 - 1.00e+00 1.48e-01f 1\n", + " 17r 0.0000000e+00 3.18e-01 8.47e+01 -0.2 1.39e-01 - 1.00e+00 1.00e+00f 1\n", + " 18r 0.0000000e+00 3.18e-01 6.25e+01 -0.2 1.96e-01 - 1.00e+00 1.00e+00f 1\n", + " 19r 0.0000000e+00 3.18e-01 5.46e+00 -0.2 3.26e-02 - 1.00e+00 1.00e+00h 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 20r 0.0000000e+00 3.18e-01 1.44e+02 -1.6 2.34e-01 - 1.00e+00 1.00e+00f 1\n", + " 21r 0.0000000e+00 3.18e-01 1.45e+01 -1.6 9.26e-02 - 9.13e-01 1.00e+00f 1\n", + " 22r 0.0000000e+00 3.18e-01 1.46e+01 -1.6 1.71e-01 - 1.00e+00 1.25e-01f 4\n", + " 23r 0.0000000e+00 3.18e-01 1.44e+01 -1.6 1.24e-01 - 1.00e+00 1.56e-02h 7\n", + " 24r 0.0000000e+00 3.18e-01 1.41e+01 -1.6 1.27e-01 - 1.00e+00 1.56e-02h 7\n", + " 25r 0.0000000e+00 3.18e-01 1.39e+01 -1.6 1.24e-01 - 1.00e+00 1.56e-02h 7\n", + " 26r 0.0000000e+00 3.18e-01 1.37e+01 -1.6 1.22e-01 - 1.00e+00 1.56e-02h 7\n", + " 27r 0.0000000e+00 3.18e-01 1.35e+01 -1.6 1.20e-01 - 1.00e+00 1.56e-02h 7\n", + " 28r 0.0000000e+00 3.18e-01 1.33e+01 -1.6 1.18e-01 - 1.00e+00 1.56e-02h 7\n", + " 29r 0.0000000e+00 3.18e-01 1.31e+01 -1.6 1.17e-01 - 1.00e+00 1.56e-02h 7\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 30r 0.0000000e+00 3.18e-01 1.29e+01 -1.6 1.15e-01 - 1.00e+00 1.56e-02h 7\n", + " 31r 0.0000000e+00 3.18e-01 1.28e+01 -1.6 1.13e-01 - 1.00e+00 1.56e-02h 7\n", + " 32r 0.0000000e+00 3.18e-01 4.28e+01 -1.6 1.11e-01 - 1.00e+00 1.00e+00w 1\n", + " 33r 0.0000000e+00 3.18e-01 1.43e-03 -1.6 2.09e-05 - 1.00e+00 1.00e+00w 1\n", + " 34r 0.0000000e+00 3.18e-01 1.37e+01 -3.7 6.94e-02 - 1.00e+00 1.00e+00f 1\n", + " 35r 0.0000000e+00 3.17e-01 3.73e+04 -3.7 3.39e+00 - 1.44e-01 1.00e+00f 1\n", + " 36r 0.0000000e+00 3.17e-01 6.78e+03 -3.7 5.99e-01 - 1.00e+00 1.00e+00f 1\n", + " 37r 0.0000000e+00 3.17e-01 7.66e+00 -3.7 4.92e-03 - 1.00e+00 1.00e+00h 1\n", + " 38r 0.0000000e+00 3.17e-01 1.65e-04 -3.7 9.43e-05 - 1.00e+00 1.00e+00h 1\n", + " 39r 0.0000000e+00 3.17e-01 1.30e+00 -5.6 9.94e-04 - 1.00e+00 1.00e+00f 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 40r 0.0000000e+00 3.11e-01 6.82e+04 -5.6 3.21e+01 - 1.41e-01 1.00e+00f 1\n", + " 41r 0.0000000e+00 3.11e-01 1.17e+01 -5.6 4.97e-03 - 1.00e+00 1.00e+00h 1\n", + "\n", + "Number of Iterations....: 41\n", + "\n", + " (scaled) (unscaled)\n", + "Objective...............: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Dual infeasibility......: 2.2783833299154238e-01 2.2783833299154238e-01\n", + "Constraint violation....: 3.1132475345243688e-01 3.1132475345243688e-01\n", + "Complementarity.........: 2.7808801399127131e-06 2.7808801399127131e-06\n", + "Overall NLP error.......: 3.1132475345243688e-01 3.1132475345243688e-01\n", + "\n", + "\n", + "Number of objective function evaluations = 109\n", + "Number of objective gradient evaluations = 3\n", + "Number of equality constraint evaluations = 109\n", + "Number of inequality constraint evaluations = 0\n", + "Number of equality constraint Jacobian evaluations = 44\n", + "Number of inequality constraint Jacobian evaluations = 0\n", + "Number of Lagrangian Hessian evaluations = 42\n", + "Total CPU secs in IPOPT (w/o function evaluations) = 0.008\n", + "Total CPU secs in NLP function evaluations = 0.000\n", + "\n", + "EXIT: Converged to a point of local infeasibility. Problem may be infeasible.\n" + ] + } + ], + "source": [ + "perturb_model(m)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As can be seen from the solver logs, IPOPT was unable to find a feasible solution to this problem, and went into restoration from the first iteration. However, there is no reason the perturbed conditions should not be feasible (you can verify this with the `infeasibility_explainer` in the Diagnostics Toolbox if you desire).\n", + "\n", + "There are a few reasons for this, most of which can be resolved by providing better scaling for the model. One of the reasons is because we have a number of concentrations approaching zero which results in a number of very small numbers appearing in the problem.\n", + "\n", + "A bigger issue however is the fact that in our initial model we are feeding reactants in stoichiometric amounts (1:1) meaning that both reactant concentrations go to zero at equilibrium. This results in the Jacobian for the reaction rate constraint becoming singular; with `rate = K_rxn * [NaOH] * [EthylAcetate]` if both concentrations go to zero then the partial derivative of the reaction rate with respect to each concentration is also 0, and thus our solver has no idea of what direction to move when trying to converge the problem. Whilst scaling can help work around this, this is ultimately an indication that our problem is not well formulated. In practice, an Equilibrium reactor model is not well suited for systems involving irreversible rate-based reactions as it requires concentrations to be driven to zero, and is an especially poor choice for stoichiometric feeds." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2: Understanding the Model\n", + "\n", + "Now that we have a test case (or multiple test cases), we can start planning out the new scaling routine. As our goal is to estimate scaling factors for as many of the variables and constraints in the model as possible, the first step is to understand what variables and constraints may be present in the model. Note that we need to be careful to check for all variables and constraints that may exist under different configuration options, and not just those that appear in the our test case(s).\n", + "\n", + "Given the modular nature of IDAES, we need to also make a distinction between those variables and constraints we have direct knowledge of, and those that are created via modular sub-models that we do not know the details of. The most common examples of modular sub-models are the ``StateBlocks`` and ``ReactionBlocks`` created by the associated property packages; we know that these exist and we create these in our models, but we do not know what variables and constraints they may construct. On the other hand, we also have variables and constraints that we construct directly in our model. For the purposes of this we include those variables and constraints constructed by ``ControlVolumes`` as being directly construed; whilst the ``ControlVolume`` might automate the details for us, we directly call methods on the ``ControlVolume`` to create these variables and constraints and we know what they will be based on the instructions we give.\n", + "\n", + "For our example of the ``EquilibriumReactor``, let us take a look at the code in the ``build`` method, which has been copied below for convenience:" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "def build(self):\n", + " \"\"\"\n", + " Begin building model.\n", + "\n", + " Args:\n", + " None\n", + "\n", + " Returns:\n", + " None\n", + " \"\"\"\n", + " # Call UnitModel.build to setup dynamics\n", + " super(EquilibriumReactorData, self).build()\n", + "\n", + " # Build Control Volume\n", + " self.control_volume = ControlVolume0DBlock(\n", + " dynamic=self.config.dynamic, # Config block forces this to be False\n", + " has_holdup=self.config.has_holdup, # Config block forces this to be False\n", + " property_package=self.config.property_package,\n", + " property_package_args=self.config.property_package_args,\n", + " reaction_package=self.config.reaction_package,\n", + " reaction_package_args=self.config.reaction_package_args,\n", + " )\n", + "\n", + " # No need for control volume geometry\n", + "\n", + " self.control_volume.add_state_blocks(\n", + " has_phase_equilibrium=self.config.has_phase_equilibrium\n", + " )\n", + "\n", + " self.control_volume.add_reaction_blocks(\n", + " has_equilibrium=self.config.has_equilibrium_reactions\n", + " )\n", + "\n", + " self.control_volume.add_material_balances(\n", + " balance_type=self.config.material_balance_type,\n", + " has_rate_reactions=self.config.has_rate_reactions,\n", + " has_equilibrium_reactions=self.config.has_equilibrium_reactions,\n", + " has_phase_equilibrium=self.config.has_phase_equilibrium,\n", + " )\n", + "\n", + " self.control_volume.add_energy_balances(\n", + " balance_type=self.config.energy_balance_type,\n", + " has_heat_of_reaction=self.config.has_heat_of_reaction,\n", + " has_heat_transfer=self.config.has_heat_transfer,\n", + " )\n", + "\n", + " self.control_volume.add_momentum_balances(\n", + " balance_type=self.config.momentum_balance_type,\n", + " has_pressure_change=self.config.has_pressure_change,\n", + " )\n", + "\n", + " # Add Ports\n", + " self.add_inlet_port()\n", + " self.add_outlet_port()\n", + "\n", + " if self.config.has_rate_reactions:\n", + " # Add equilibrium reactor performance equation\n", + " @self.Constraint(\n", + " self.flowsheet().time,\n", + " self.config.reaction_package.rate_reaction_idx,\n", + " doc=\"Rate reaction equilibrium constraint\",\n", + " )\n", + " def rate_reaction_constraint(b, t, r):\n", + " # Set kinetic reaction rates to zero\n", + " return b.control_volume.reactions[t].reaction_rate[r] == 0\n", + "\n", + " # Set references to balance terms at unit level\n", + " if (\n", + " self.config.has_heat_transfer is True\n", + " and self.config.energy_balance_type != EnergyBalanceType.none\n", + " ):\n", + " self.heat_duty = Reference(self.control_volume.heat[:])\n", + "\n", + " if (\n", + " self.config.has_pressure_change is True\n", + " and self.config.momentum_balance_type != MomentumBalanceType.none\n", + " ):\n", + " self.deltaP = Reference(self.control_volume.deltaP[:])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If we look through the code in the ``build`` method, we can see that the model contains a single 0D Control Volume with ``StateBlocks``, a ``ReactionBlock``, material, energy and momentum balances and one additional constraint (``rate_reaction_constraint``). Thus, we have the following components that need to be scaled:\n", + "\n", + "3 Sub-Models:\n", + "\n", + "1. The inlet state sub-model (``model.control_volume.properties_in``)\n", + "2. The outlet state sub-model (``model.control_volume.properties_out``)\n", + "3. The reaction sub-model (``model.control_volume.reactions``)\n", + "\n", + "Unit Model Variables (from control volume options):\n", + "\n", + "1. Rate-based reaction extent and generation terms\n", + "2. Equilibrium-based reaction extent and generation terms\n", + "3. Inherent reaction extent and generation terms (no explicit argument, but determined by properties)\n", + "4. Phase equilibrium generation terms\n", + "5. Energy balance heat term\n", + "6. Energy balance heats of reaction\n", + "7. Pressure drop\n", + "\n", + "Unit Model Constraints (from control volume + 1 in the ``build`` method):\n", + "\n", + "1. Material balance constraints\n", + "2. Reaction stoichiometry constraints\n", + "3. Energy balance constraints\n", + "4. Pressure balance constraints\n", + "5. ``rate_reaction_constraint``\n", + "\n", + "When writing our ``Scaler`` we will need to consider all of these to determine how best to estimate scaling factors. Before starting however, we should check the numerical diagnostics for each case study, both to see what scaling issues currently exist and to establish a baseline for comparison once we have a proposed ``Scaler`` for our model.\n", + "\n", + "The cell below calls the ``report_numerical_issues`` method for the unscaled test case." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 1.540E+12\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 1 Constraint with large residuals (>1.0E-05)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "7 Cautions\n", + "\n", + " Caution: 1 Variable with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme value (<1.0E-04 or >1.0E+04)\n", + " Caution: 1 Constraint with mismatched terms\n", + " Caution: 3 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_constraints_with_large_residuals()\n", + " compute_infeasibility_explanation()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "dt.report_numerical_issues()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Looking at the results of the diagnostics, we can see that the test case is not particularly well scaled. The Jacobian condition number is rather large (1e12), and the diagnostics are reporting a number of variables with extremely large or small values, and 3 variables and 2 constraints with poorly scaled Jacobians. As we develop our new ``Scaler`` for the ``EquilibriumReactor`` we will hopefully see these improve.\n", + "\n", + "We can also use the Diagnostics Toolbox to further explore these issues to get a better idea of which variables and constraints might be causing issues. For example, lets display the set of variables and constraints with extreme Jacobian norms." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "====================================================================================\n", + "The following variable(s) are associated with extreme Jacobian values (<1.0E-04 or>1.0E+04):\n", + "\n", + " fs.equil.control_volume.properties_out[0.0].flow_vol: 9.427E+07\n", + " fs.equil.control_volume.properties_out[0.0].temperature: 4.172E+06\n", + " fs.equil.control_volume.rate_reaction_extent[0.0,R1]: 4.900E+04\n", + "\n", + "====================================================================================\n", + "====================================================================================\n", + "The following constraint(s) are associated with extreme Jacobian values (<1.0E-04 or>1.0E+04):\n", + "\n", + " fs.equil.control_volume.enthalpy_balances[0.0]: 9.436E+07\n", + " fs.equil.control_volume.material_balances[0.0,Liq,H2O]: 5.539E+04\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "dt.display_variables_with_extreme_jacobians()\n", + "dt.display_constraints_with_extreme_jacobians()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "These diagnostics can help give us an idea of what may be causing problems in our model. From the output above, we can see that the variables with large Jacobian norms (i.e., high sensitivities) are the outlet flow rate and temperature, as well as the rate-based extent of reaction. We can also see that the constraints with large Jacobian norms are the enthalpy balance and H20 material balance for the reactor. However, caution must be used when interpreting these in isolation, as understanding what these mean is often complicated and initial impressions may be misleading. To get a better picture of what is contributing to extreme Jacobian values you should make use of the tools in the diagnostics ``SVDToolbox``, however that is a topic for another example.\n", + "\n", + "For example, one might wonder why the volumetric flow rate at the outlet of the reactor is so important as it is effectively determined by the inlet flow rate (due to the water balance effectively conserving volume). However, it is important to remember that the Jacobian does not consider the value of the variable, but rather its partial derivatives. Thus, it is important to compare the list of variables and constraints with large Jacobian norms and think about how those intersect.\n", + "\n", + "Let's start by taking a look at the H2O material balance. The cell below prints the constraint expression in a compact form that only shows top level ``Expressions`` rather than expanding these to show the full expression tree." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fs.equil.control_volume.properties_in[0.0].flow_vol*fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] - fs.equil.control_volume.properties_out[0.0].flow_vol*fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] + fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] == 0" + ] + } + ], + "source": [ + "from idaes.core.util.misc import print_compact_form\n", + "\n", + "print_compact_form(m.fs.equil.control_volume.material_balances[0, \"Liq\", \"H2O\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Looking at how the outlet volumetric flowrate appears in the H2O balance equation above, it can be seen that the volumetric flow term is multiplied by the molar concentration of water, $F \\times C_{H2O}$. Whilst $C_{H2O}$ is assumed to be constant in this model (and equal to the molar density of pure water at ambient conditions), this means that the partial derivative of the constraint term with respect to flow is $\\frac{\\partial F C_{H2O}}{\\partial F} = C_{H2O}$; given that $C_{H2O}$ is equal to 5.5E4 mol/liter, you can quickly see why it is being identified as an issue.\n", + "\n", + "If we look at the energy balance, we will find that it is similar." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "fs.properties.dens_mol*fs.properties.cp_mol*fs.equil.control_volume.properties_in[0.0].flow_vol*(fs.equil.control_volume.properties_in[0.0].temperature - fs.properties.temperature_ref) - fs.properties.dens_mol*fs.properties.cp_mol*fs.equil.control_volume.properties_out[0.0].flow_vol*(fs.equil.control_volume.properties_out[0.0].temperature - fs.properties.temperature_ref) + fs.equil.control_volume.heat[0.0] + fs.equil.control_volume.heat_of_reaction[0.0] == 0" + ] + } + ], + "source": [ + "print_compact_form(m.fs.equil.control_volume.enthalpy_balances[0.0])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Whilst a bit harder to read due to the size of the constraint, you can see that it involves the term $\\rho \\times c_p \\times F \\times (T - T_{ref})$, where $c_p$ is the specific molar heat capacity of the solution, $T$ is temperature and $T_{ref}$ is the reference temperature. Given that $\\rho$ is of order 1E4 (a) and $c_p \\times (T-T_{ref})$ is of order 1E3, this means that the partial derivative with respect to the volumetric flowrate is even larger than that for the H2O balance. This also explains the appearance of the outlet temperature as well, as we can see that it is multiplied by a number of large values as well and thus has a large partial derivative.\n", + "\n", + "It is also important to mention that having a large value in the Jacobian does not mean a variable is \"important\" (and conversely a small value is not unimportant). What is important is how sensitive the constraint residual is to that change in variable, which is often difficult to assess from the Jacobian alone (which is where the ``SVDToolbox`` can assist).\n", + "\n", + "\n", + "## Step 3: Creating a New Scaler Class\n", + "\n", + "To create a new scaling routine for the equilibrium reactor, we start by creating a new ``Scaler`` class which inherits from the ``CustomScalerBase`` class in ``idaes.core.scaling``. The ``CustomScalerBase`` class contains a number of useful methods to help us in developing our scaling routine, including some placeholder methods for implementing a standard scaling workflow and helper methods for doing common tasks.\n", + "\n", + "The cell below shows how to create our new class which we will name ``EquilibriumReactorScaler`` as well as two key methods we will fill out as part of this workshop." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.scaling import CustomScalerBase\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ``variable_scaling_routine`` and ``constraint_scaling_routine`` methods are used to implement subroutines for scaling the variables and constraints in the model respectively. Separately, there is a ``scale_model`` method that will call each of these in sequence in order to scale an entire model by applying the following steps:\n", + "\n", + "1. apply variable scaling routine,\n", + "2. apply first stage scaling fill-in,\n", + "3. apply constraint scaling routine,\n", + "4. apply second stage scaling fill-in.\n", + "\n", + "The second and fourth steps are intended to allow users to provide methods to fill in missing scaling information that was not provided by the first and second steps, or to provide a way to update the scaling factors with more information.\n", + "\n", + "Both the ``variable_scaling_routine`` and ``constraint_scaling_routine`` are user-facing methods and take three arguments.\n", + "\n", + "1. The model to be scaled.\n", + "2. An argument indicating whether to overwrite any existing scaling factors. Generally we assume that any existing scaling factors were provided by the user for a reason, so by default we set this to ``False``. However, there will likely be cases where a user wants to overwrite their existing scaling factors so this argument exists to let us pass on those instructions.\n", + "3. A mapping of user-provided ``Scalers`` to use when scaling submodels.\n", + "\n", + "## Step 4: Apply Scaling to Sub-Models\n", + "\n", + "First, lets look at how to scale the property and reaction sub-models. As these are modular packages, we do not know what variables and constraints may be in them, so we cannot (and should not) scale any of these directly. However, we can (hopefully) assume that there are ``Scalers`` available for these sub-models, either through default ``Scalers`` associated with the property packages or provided by the user. Thus, what we want to do here is to call the variable and constraint scaling routines from the ``Scaler`` associated with each sub-model, which we can do using the ``call_submodel_scaler_method`` method from the ``CustomScalerBase`` class.\n", + "\n", + "The cell below prints the doc-string for the ``call_submodel_scaler_method`` method so we can see what the expected arguments are." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Help on function call_submodel_scaler_method in module idaes.core.scaling.custom_scaler_base:\n", + "\n", + "call_submodel_scaler_method(self, submodel, method: str, submodel_scalers: pyomo.common.collections.component_map.ComponentMap = None, overwrite: bool = False)\n", + " Call scaling method for submodel.\n", + " \n", + " Scaler for submodel is taken from submodel_scalers if present, otherwise the\n", + " default scaler for the submodel is used.\n", + " \n", + " Args:\n", + " submodel: submodel to be scaled\n", + " submodel_scalers: user provided ComponentMap of Scalers to use for submodels\n", + " method: name of method to call from submodel (as string)\n", + " overwrite: whether to overwrite existing scaling factors\n", + " \n", + " Returns:\n", + " None\n", + "\n" + ] + } + ], + "source": [ + "help(CustomScalerBase.call_submodel_scaler_method)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that ``call_submodel_scaler_method`` takes 4 arguments:\n", + "\n", + "1. ``submodel`` is the submodel we want to scale. \n", + "2. The ``submodel_scalers`` argument should be passed through from the ``variable_scaling_routine`` or ``constraint_scaling_routine`` method.\n", + "3. The name of the method we want to call from the ``Scaler`` when we get it - this will normally be either ``variable_scaling_routine`` (if we are scaling variables) or ``constraint_scaling_routine`` (if we are doing constraints).\n", + "4. The ``overwrite`` argument should also be passed through from the ``variable_scaling_routine`` or ``constraint_scaling_routine`` method.\n", + "\n", + "For the Equilibrium Reactor, we have three submodels to scale; inlet state, outlet state and reactions. As mentioned in the introduction, when developing scaling routines always start with the things you have the most information about. In this case, we likely know the most about the inlet state; either it is a defined feed state (like in our test case) or we have some idea of the state (and scaling) from propagating values from an upstream operation. So, to apply variable scaling to the inlet state we would do the following:\n", + "\n", + "```python\n", + "self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + ")\n", + "```\n", + "\n", + "Once we have an idea of scaling for the inlet we can use that information to try to estimate scaling for the outlet state. The default assumption is that the scaling of the outlet will be similar to that of the inlet, so the easy path is to copy scaling from the inlet state to the outlet. However, we know that something must change between inlet and outlet (as otherwise this unit operation is doing nothing) so we should always stop and think about whether we can try to estimate these changes. For example, in a pressure changer we know, or be able to estimate, the pressure change across the unit and thus be able to change the scaling of pressure between the inlet and outlet. However, keep in mind that over-scaling can make things worse so be judicious when deciding whether to adjust scaling based on estimates.\n", + "\n", + "In regards to this, Equilibrium Reactors are one of the more challenging units to scale, as it is very hard to know what the outlet flows and concentrations will be without knowing what the reactions are (and even if you know the reactions it is often hard to know the equilibrium state). In most cases, we have no reliable way to estimate the outlet flowrate and concentrations, so this is best left to the user to provide. In the case of temperature and pressure, whilst we may expect these to change but any change will generally be 1-2 orders of magnitude less than the inlet state and thus the overall scale of these will likely remain similar. Thus, for the Equilibrium Reactor it is probably sufficient to just scale the outlet state based on the inlet state.\n", + "\n", + "The ``CustomScalerBase`` class has a method for propagating scaling factors for state variables from one state to another called ``propagate_state_scaling`` as see below." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Help on function propagate_state_scaling in module idaes.core.scaling.custom_scaler_base:\n", + "\n", + "propagate_state_scaling(self, target_state, source_state, overwrite: bool = False)\n", + " Propagate scaling of state variables from one StateBlock to another.\n", + " \n", + " Indexing of target and source StateBlocks must match.\n", + " \n", + " Args:\n", + " target_state: StateBlock to set scaling factors on\n", + " source_state: StateBlock to use as source for scaling factors\n", + " overwrite: whether to overwrite existing scaling factors\n", + " \n", + " Returns:\n", + " None\n", + "\n" + ] + } + ], + "source": [ + "help(CustomScalerBase.propagate_state_scaling)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Here we can see that ``propagate_state_scaling`` takes three arguments; the ``StateBlock`` we want to apply scaling to, the ``StateBlock`` we want to use as the source for the scaling factors, and the ``overwrite`` argument. Thus, we can propagate scaling from the inlet state to the outlet state as shown below.\n", + "\n", + "```python\n", + "self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + ")\n", + "```\n", + "\n", + "This only propagates scaling factors for the state variables, however, so we should then call the ``Scaler`` for the outlet state block to scale any remaining variables and constraints (which will hopefully make use of the scaling factors for the state variables we just propagated).\n", + "\n", + "We can then move on to scaling the ``ReactionBlock``. ``ReactionBlocks`` are slightly unusual in that they rely heavily on the state variables defined in a separate ``StateBlock`` - in this case the outlet state block. As we just applied a ``Scaler`` to the outlet state block, we can assume that all of the necessary variables have been scaled so all we need to do now is call a ``Scaler`` for the ``ReactionBlock``.\n", + "\n", + "All of this is shown in the cell below." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Empty method for now\n", + " pass" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can then take a similar approach for the constraint scaling routine as shown below. Note that there is no need for a propagation step here as the residual of a constraint is derived from the value of the variables (which we handled in the variable scaling step)." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets do a quick check to see if our new scaler works and how it has affected the model scaling. The cell below creates a function that builds a new instance of the model (to avoid contamination from previous model runs then creates an instance of our new scaler and applies it to the model. We then solve the scaled model (adding scaling changes constraint residuals so we want to solve to the scaled state). Finally, the function prints a report of the scaling factors in the model and calls the ``report_numerical_issues`` method from the Diagnostics Toolbox." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import check_optimal_termination, TransformationFactory\n", + "\n", + "from idaes.core.scaling import report_scaling_factors\n", + "\n", + "\n", + "def check_scaling(tee=False):\n", + " # Build new instance of model\n", + " m = build_model()\n", + "\n", + " # Apply scaler to model\n", + " scaler = EquilibriumReactorScaler()\n", + " scaler.scale_model(m.fs.equil)\n", + "\n", + " # Solve scaled model\n", + " results = solver.solve(m, tee=tee)\n", + " if check_optimal_termination(results):\n", + " print(\"\\nModel Solved\\n\")\n", + " else:\n", + " print(\"\\nModel Failed to Converge!\\n\")\n", + "\n", + " # Print report of scaling factors\n", + " report_scaling_factors(m.fs.equil, descend_into=True)\n", + "\n", + " # Show numerical issues report\n", + " sm = TransformationFactory(\"core.scale_model\").create_using(m, rename=False)\n", + "\n", + " dt = DiagnosticsToolbox(model=sm.fs.equil)\n", + " dt.report_numerical_issues()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Lets run the ``check_scaling`` function and see how the model scaling has changed." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.enthalpy_balances[0.0] None\n", + "fs.equil.control_volume.pressure_balance[0.0] None\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.022E+09\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "5 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "From the scaling factor report, we can see that by calling the submodel scalers we have already scaled many of the variables in our problem, as well as three of the constraints. If we look at the \"Scaled Value\" column for the variables, we can also see that most of the scaled values are close to 1 (the few outliers might be things we want to look into more later on).\n", + "\n", + "From the numerical diagnostics, we can see that the Jacobian condition number has decreased by a few orders of magnitude, although it is still large, whilst we still have a number of potential issues with individual variables and constraints. All up though, this appears to be a step in the right direction." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5: Apply Variable Scaling\n", + "\n", + "Next, we need to look at scaling the variables and constraints that make up the unit model itself. From a conceptual standpoint, it is generally easiest to start with the variables as we generally have at least some idea of the magnitude of these.\n", + "\n", + "For the equilibrium reactor, we have the following variables we need to scale:\n", + "\n", + "1. Rate-based reaction extent and generation terms\n", + "2. Equilibrium-based reaction extent and generation terms\n", + "3. Inherent reaction extent and generation terms\n", + "4. Phase equilibrium generation terms\n", + "5. Energy balance heat term\n", + "6. Energy balance heats of reaction\n", + "7. Pressure drop\n", + "\n", + "Many of these are hard to know a priori - anything related to a reaction is very hard to know without knowing the reaction behavior. Considering that the equilibrium reactor is modular, we have little to no way of knowing these in the general case (and even in the specific test case it is hard enough). We can assume that the reaction package will scale all of its variables (i.e., rate and equilibrium constants, and reaction rates), however it is hard to project these to unit model scaling.\n", + "\n", + "For a CSTR we can say that ``extent = volume*rate`` and thus estimate scaling, but this does not work for equilibrium systems where 1) volume is undefined, 2) reaction rate at the outlet state is being driven to zero to satisfy equilibrium, and 3) extent is solved implicitly to satisfy the need for reaction rate to equal zero.\n", + "\n", + "Considering that a bad guess is often worse than no guess, we will not scale these right now - it is important to remember that our goal is to improve the overall scaling so if we do not know how to scale something it is generally best to leave it unscaled. We might come back to these later if necessary, but for now we will leave these either for the user to provide based on knowledge of their system, or for automated fill-in using some autoscaler.\n", + "\n", + "For the heat and deltaP terms, these are dependent on extensive variables in each case study and we have no way of knowing their exact values. However, we can probably take a good guess at order-of-magnitude using engineering knowledge; heat duties are generally approximately one order of magnitude smaller than the enthalpy flows,\n", + "and pressure drops are generally on the order of 0.1 bar.\n", + "\n", + "To apply scaling for the pressure drop term, we can make use of the ``scale_variable_by_units`` method in ``CustomScalerBase``. This method looks up the units of measurement for the variable, and then loops in the class attribute ``UNIT_SCALING_FACTORS`` dictionary to find an equivalent unit for the quantity of interest and an associated scaling factor. If a scaling factor is found, it is converted as necessary; e.g., in this case pressure is defined in ``Pa`` but we can set the default scaling factor in ``bar`` and it will be converted as appropriate. The code required to do this is below.\n", + "\n", + "```python\n", + "UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + "}\n", + "\n", + "def variable_scaling_routine(*args, **kwargs):\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t],\n", + " overwrite=overwrite\n", + " )\n", + "```\n", + "\n", + "There are a few things to note here:\n", + "\n", + "1. As we expect the pressure drop to be on the order of 0.1 bar, we need to set a scaling factor of 10 for quantities with units of pressure. Also note that the key ``\"Pressure Change\"`` is for documentation purposes only and is not actually used by the code (but must be there). \n", + "\n", + "
\n", + "NOTE We cannot distinguish between different quantities with the same apparent units (e.g., we cannot distinguish between an absolute pressure and a pressure change).\n", + "
\n", + "\n", + "2. Note that scaling is applied to elements of indexed components and not to the indexed component as a whole, and thus we need to use a ``for`` loop to iterate over the time index. This is done to force modelers to consider how the scaling of a variable or constraint will vary over the indexed domain, and try to discourage automatically setting a single scaling factor for all points.\n", + "3. Pressure change is a configuration argument in our unit model, and thus may not be present in all cases. Therefore, we need the ``hasattr`` check to see if we need to scale ``deltaP`` or not.\n", + "\n", + "For the case of the heat duty, we want to scale based on the incoming enthalpy flow which means we first need to get the expected magnitude of the enthalpy flow. For that, we can use the ``get_expression_nominal_values`` method in ``CustomScalerBase`` which uses an expression walker to go through an expression to return a list of the expected magnitude (or nominal value) of all additive terms in the expression based on the scaling factors for the variables involved.\n", + "\n", + "We can get an expression for the enthalpy flow term using the ``get_enthalpy_flow_terms`` method from the associated ``StateBlock``. We should assume this expression might contain multiple terms, so we should sum all the values returned to get the overall magnitude of the enthalpy flow term. Once we have this, we can then get the scaling factor for the heat duty by ``sf = abs(1/(0.1*enthalpy_flow))`` - note that the tools insist on scaling factors being positive (for sanity) and thus we need the absolute value here in case enthalpy flow is negative (which is not uncommon for enthalpy). The code to do this is shown below.\n", + "\n", + "```python\n", + "if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[t].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is general one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(model.control_volume.heat[t], abs(1 / (0.1 * h_in)))\n", + "```\n", + "\n", + "Putting all of this together results in the code below for our ``EquilibriumReactorScaler`` class." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "from pyomo.environ import units\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " # =======================================================================================\n", + " # New Code\n", + " UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + " }\n", + " # =======================================================================================\n", + "\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # =======================================================================================\n", + " # New Code\n", + "\n", + " # Pressure drop - optional\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t], overwrite=overwrite\n", + " )\n", + "\n", + " # Heat transfer - optional\n", + " # Scale heat based on enthalpy flow entering reactor\n", + " if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[\n", + " t\n", + " ].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is generally one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(\n", + " model.control_volume.heat[t], abs(1 / (0.1 * h_in))\n", + " )\n", + " # =======================================================================================\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once again, lets run the ``check_scaling`` function and see how we are going." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] 4.794E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] 1.000E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] None\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] None\n", + "fs.equil.control_volume.enthalpy_balances[0.0] None\n", + "fs.equil.control_volume.pressure_balance[0.0] None\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.022E+09\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "5 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 4 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 2 Constraints with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 6 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Our updates have resulted in scaling factors for ``heat`` and ``deltaP`` appearing in the scaling report which is good, but comparing the diagnostics from the previous step we can see that the Jacobian condition number has not changed. Does this mean we did something wrong?\n", + "\n", + "The answer is no - when we add a scaling factor to a variable, wherever that variable appears in a constraint it is replaced with ``sf*v_scaled``. Given that ``v_scaled = v/sf``, this means that for variables which only appear linearly in constraints then the partial derivative with respect to the scaled variable does not change either; thus the Jacobian is unaffected by scaling only the linear variables. In the case of this example, it turns out that almost all the variables appear linearly and thus we see no change in the Jacobian condition number.\n", + "\n", + "
\n", + "NOTE It is important to note that partial scaling of a model (e.g., variables only) can often appear worse than that of the unscaled model. Generally, it is best to wait until you have scaled both variables and constraints to make a decision on whether your attempts at scaling have made the problem better or worse, and you should not be discouraged if things look worse while in an intermediate state.\n", + "
\n", + "\n", + "\n", + "## Step 6: Apply Constraint Scaling\n", + "\n", + "Now that we have scaled all the variables that we can (for now at least), we can move on to scaling constraints. The advantage of scaling all the variables first means that now we have an idea of the expected magnitude for all terms in the constraints which we can use to estimate scaling factors. For the Equilibrium reactor model, we need to scale all the constraints in the control volume, as well as the unit level constraint equating all reaction rates to zero.\n", + "\n", + "There are many approaches to estimating scaling for constraints, and different approaches are better suited to certain situations. ``CustomScalerBase`` contains a ``scale_constraint_by_nominal_value`` method which can be used to automatically implement a number of common approaches to save you the effort of having to manually implement these yourself. As of writing, the approaches (or schemes) supported are:\n", + "\n", + "1. ``ConstraintScalingScheme.inverseMaximum`` - scale the constraint based on the term with the largest absolute expected magnitude. This is scheme is useful for cases where most terms have similar magnitudes and is a good initial point to start.\n", + "2. ``ConstraintScalingScheme.inverseMinimum`` - scale the constraint based on the term with the smallest absolute expected magnitude. This scheme is similar to the inverse maximum scheme and is useful for cases where you have a constraint with a number of smaller terms mixed with a few larger terms, or cases where the smaller term is expected to be most significant. This scheme should be used carefully however as it can result in large scaling factors making convergence of larger terms difficult.\n", + "3. ``ConstraintScalingScheme.harmonicMean`` - scale the constraint using the harmonic mean of the absolute expected magnitude of all terms (``sf = sum(1/abs(nominal value))``). This scheme is most useful when you have a constraint with terms with a mix of expected magnitudes where you need to find a balance between the large and small terms.\n", + "4. ``ConstraintScalingScheme.inverseSum`` - scale the constraint using the sum of the absolute expected magnitudes of all terms. Situationally useful for cases with terms of mixed magnitudes.\n", + "5. ``ConstraintScalingScheme.inverseRSS`` - scale the constraint using the root sum of squares of the absolute expected magnitudes of all terms. Situationally useful for cases with terms of mixed magnitudes.\n", + "\n", + "``CustomScalerBase`` also contains a ``scale_constraint_by_nominal_derivative_norm`` method that can scale a constraint based on an estimate of the Jacobian norm associated with that constraint which can be useful for cases where you want to focus on the Jacobian scaling.\n", + "\n", + "
\n", + "NOTE The solver you intend to use may impact which approach provides the best scaling for a given model. For example, IPOPT has very good internal Jacobian scaling (when using the `gradient-based` scaling option), and thus benefits the most from focusing on scaling the constraint residual magnitudes as opposed to the Jacobian.\n", + "
\n", + "\n", + "For this workshop, we will start by just using ``ConstraintScalingScheme.inverseMaximum`` to get a starting point and to see if further scaling is required. We can apply this scheme to scale all the constraints in the control volume using the code below.\n", + "\n", + "```python\n", + "for c in model.control_volume.component_data_objects(\n", + " Constraint, descend_into=False\n", + "):\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + "```\n", + "\n", + "Adding this and a similar approach to scale the unit level constraint gives us the code below for our ``EquilibriumreactorScaler`` class." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [], + "source": [ + "from idaes.core.scaling import ConstraintScalingScheme\n", + "\n", + "\n", + "class EquilibriumReactorScaler(CustomScalerBase):\n", + " UNIT_SCALING_FACTORS = {\n", + " # \"QuantityName: (reference units, scaling factor)\n", + " \"Pressure Change\": (units.bar, 10),\n", + " }\n", + "\n", + " def variable_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.propagate_state_scaling(\n", + " target_state=model.control_volume.properties_out,\n", + " source_state=model.control_volume.properties_in,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"variable_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # Pressure drop - optional\n", + " if hasattr(model.control_volume, \"deltaP\"):\n", + " for t in model.flowsheet().time:\n", + " self.scale_variable_by_units(\n", + " model.control_volume.deltaP[t], overwrite=overwrite\n", + " )\n", + "\n", + " # Heat transfer - optional\n", + " # Scale heat based on enthalpy flow entering reactor\n", + " if hasattr(model.control_volume, \"heat\"):\n", + " for t in model.flowsheet().time:\n", + " h_in = 0\n", + " for p in model.control_volume.properties_in.phase_list:\n", + " # The expression for enthalpy flow might include multiple terms,\n", + " # so we will sum over all the terms provided\n", + " h_in += sum(\n", + " self.get_expression_nominal_values(\n", + " model.control_volume.properties_in[\n", + " t\n", + " ].get_enthalpy_flow_terms(p)\n", + " )\n", + " )\n", + " # Scale for heat is generally one order of magnitude less than enthalpy flow\n", + " self.set_variable_scaling_factor(\n", + " model.control_volume.heat[t], abs(1 / (0.1 * h_in))\n", + " )\n", + "\n", + " def constraint_scaling_routine(\n", + " self, model, overwrite: bool = False, submodel_scalers: dict = None\n", + " ):\n", + " # Call scaling methods for sub-models\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_in,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.properties_out,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + " self.call_submodel_scaler_method(\n", + " submodel=model.control_volume.reactions,\n", + " method=\"constraint_scaling_routine\",\n", + " submodel_scalers=submodel_scalers,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # =======================================================================================\n", + " # New Code\n", + " # Scale control volume constraints\n", + " for c in model.control_volume.component_data_objects(\n", + " Constraint, descend_into=False\n", + " ):\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + "\n", + " # Scale unit level constraints\n", + " if hasattr(model, \"rate_reaction_constraint\"):\n", + " for c in model.rate_reaction_constraint.values():\n", + " self.scale_constraint_by_nominal_value(\n", + " c,\n", + " scheme=ConstraintScalingScheme.inverseMaximum,\n", + " overwrite=overwrite,\n", + " )\n", + " # =======================================================================================" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Once again, let us use the ``check_scaling`` function to see how our ``Scaler`` performs." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Model Solved\n", + "\n", + "Scaling Factors for fs.equil\n", + "\n", + "Variable Scaling Factor Value Scaled Value\n", + "fs.equil.control_volume.properties_in[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[NaOH] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 1.000E+02 1.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].conc_mol_comp[Ethanol] 1.000E-02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.properties_in[0.0].temperature 3.219E-03 3.031E+02 9.759E-01\n", + "fs.equil.control_volume.properties_in[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.properties_out[0.0].flow_vol 1.000E+02 1.000E-03 1.000E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[H2O] 1.000E-04 5.539E+04 5.539E+00\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[NaOH] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[EthylAcetate] 1.000E-02 6.250E-02 6.250E-04\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[SodiumAcetate] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].conc_mol_comp[Ethanol] 1.000E-02 9.994E+01 9.994E-01\n", + "fs.equil.control_volume.properties_out[0.0].temperature 3.219E-03 3.043E+02 9.796E-01\n", + "fs.equil.control_volume.properties_out[0.0].pressure 1.000E-05 1.013E+05 1.013E+00\n", + "fs.equil.control_volume.heat[0.0] 4.794E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.deltaP[0.0] 1.000E-04 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,H2O] None 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,NaOH] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,EthylAcetate] None -9.994E-02 -9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,SodiumAcetate] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_generation[0.0,Liq,Ethanol] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.rate_reaction_extent[0.0,R1] None 9.994E-02 9.994E-02\n", + "fs.equil.control_volume.reactions[0.0].reaction_rate[R1] 1.000E+02 0.000E+00 0.000E+00\n", + "fs.equil.control_volume.reactions[0.0].k_rxn 5.424E+00 1.304E-01 7.075E-01\n", + "\n", + "Constraint Scaling Factor\n", + "fs.equil.rate_reaction_constraint[0.0,R1] 1.000E+02\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,H2O] 1.000E+00\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,NaOH] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,EthylAcetate] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,SodiumAcetate] 1.000E+01\n", + "fs.equil.control_volume.rate_reaction_stoichiometry_constraint[0.0,Liq,Ethanol] 1.000E+01\n", + "fs.equil.control_volume.material_balances[0.0,Liq,H2O] 1.000E-02\n", + "fs.equil.control_volume.material_balances[0.0,Liq,NaOH] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,EthylAcetate] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,SodiumAcetate] 1.000E+00\n", + "fs.equil.control_volume.material_balances[0.0,Liq,Ethanol] 1.000E+00\n", + "fs.equil.control_volume.enthalpy_balances[0.0] 7.715E-08\n", + "fs.equil.control_volume.pressure_balance[0.0] 9.869E-06\n", + "fs.equil.control_volume.properties_out[0.0].conc_water_eqn 1.000E-04\n", + "fs.equil.control_volume.reactions[0.0].rate_expression[R1] 5.424E-04\n", + "fs.equil.control_volume.reactions[0.0].arrhenius_eqn 5.424E+00\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 7.182E+04\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "3 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 2 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "check_scaling()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "From the results of ``check_scaling`` we can see that we now have scaling factors for almost all the variables and constraints in the model (the only exceptions being the reaction related variables we left unscaled earlier). More importantly, we can see that the Jacobian condition number is now down to ``7.2E4`` from the original ``1.5E12`` which is an impressive improvement (and for not a lot of effort on our part). We can also see that the numerical diagnostics are no longer reporting any variables or constraints with extreme Jacobians (there are 2 individual entries that are a bit large, but it appears they are not having a big impact on the condition number).\n", + "\n", + "We do see that there are a number of variables with values close to ``0`` which we should be wary of, but in this case it is due to the case study we are using. Here we are using an equilibrium reactor to drive a rate-based reaction to completion, which necessitates that at least one reactant have a concentration of zero as well as the reaction rate for all reactions. Thus, for this case these are unavoidable. As mentioned earlier, we really should be asking whether an Equilibrium Reactor is well suited for the reaction model we have here, and a Stoichiometric Reactor would probably have been a better choice (or a better reaction package which use reversible reactions with equilibrium).\n", + "\n", + "\n", + "## Step 7: Review Scaling Routine\n", + "\n", + "We now have a new ``Scaler`` for an equilibrium reactor that uses the modular nature of IDAES to implement a general purpose scaling routine (or so we hope at least). So, does this mean we are done?\n", + "\n", + "No, or not yet at least.\n", + "\n", + "We should always take a step back and ask ourselves if what we have is good enough and see if we can see any areas where we might be able to do better, or places where edge cases might exist. As a starting point, let us first see how we compare to an autoscaling routine using the model Jacobian. We can use the ``AutoScaler.scale_model`` method for this as shown below." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: model contains export suffix 'scaling_factor' that contains 10\n", + "component keys that are not exported as part of the NL file. Skipping.\n", + "\n", + "Model Solved\n", + "\n", + "====================================================================================\n", + "Model Statistics\n", + "\n", + " Jacobian Condition Number: 3.863E+06\n", + "\n", + "------------------------------------------------------------------------------------\n", + "1 WARNINGS\n", + "\n", + " WARNING: 2 Variables at or outside bounds (tol=0.0E+00)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "4 Cautions\n", + "\n", + " Caution: 2 Variables with value close to their bounds (abs=1.0E-04, rel=1.0E-04)\n", + " Caution: 6 Variables with value close to zero (tol=1.0E-08)\n", + " Caution: 2 Variables with extreme Jacobian values (<1.0E-04 or >1.0E+04)\n", + " Caution: 7 extreme Jacobian Entries (<1.0E-04 or >1.0E+04)\n", + "\n", + "------------------------------------------------------------------------------------\n", + "Suggested next steps:\n", + "\n", + " display_variables_at_or_outside_bounds()\n", + "\n", + "====================================================================================\n" + ] + } + ], + "source": [ + "from idaes.core.scaling import AutoScaler\n", + "\n", + "m = build_model()\n", + "\n", + "scaler = EquilibriumReactorScaler()\n", + "autoscaler = AutoScaler()\n", + "\n", + "autoscaler.scale_model(m)\n", + "\n", + "solver = get_solver(\n", + " \"ipopt_v2\", writer_config={\"scale_model\": True, \"linear_presolve\": True}\n", + ")\n", + "results = solver.solve(m)\n", + "\n", + "if check_optimal_termination(results):\n", + " print(\"\\nModel Solved\\n\")\n", + "else:\n", + " print(\"\\nModel Failed to Converge!\\n\")\n", + "\n", + "sm = TransformationFactory(\"core.scale_model\").create_using(m, rename=False)\n", + "\n", + "dt = DiagnosticsToolbox(model=sm.fs.equil)\n", + "dt.report_numerical_issues()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that our ``EquilibriumReactorScaling`` routine actually results in a lower Jacobian condition number than the ``AutoScaler`` approach, so that is a sign we are doing things right. It is not unusual to see that we can get better scaling with a manual, magnitude based approach than an autoscaler as the autoscaler focuses solely on the Jacobian and thus often over-scales the problem.\n", + "\n", + "However, we might be able to do better by using other constraint scaling schemes, but before we start experimenting we should stop and think about what sort of scaling might make sense for each constraint. We should always also keep in the back of our minds whether additional work is worth the effort, and if we risk over-tuning the scaling for the specific property package we have.\n", + "\n", + "Fortunately, the model in this example is fairly simple and we do not have too many constraints to consider. Firstly, we have the unit-level constraint that says that `rate_reaction == 0` for all rate-based reactions. When considering scaling of a constraint we should ignore any 0 terms, thus this constraint has only 1 term and so we should scale based on this. If we use the ``scale_constraint_by_nominal_value`` method for this it will ignore the zero for us, the scheme used does not actually matter as there is only one term to consider.\n", + "\n", + "Next, we have the balance equations which all have the form `0 == In - Out + Gen` - note the equilibrium reactor does not support dynamics so we don't need to think about that. Generation terms can vary a lot, but we basically have two possible cases:\n", + "\n", + "1. one term is negligible compared to the other 2, so we should scale based on one of the significant\n", + "terms, or\n", + "2. all three terms are of similar significance (e.g., inlet and gen are of similar scale and outlet\n", + "is ~inletx2). Here we could scale based on the harmonic mean, by the maximum term is probably not bad either.\n", + "\n", + "So, in short the maximum magnitude is probably the best general-purpose scale for these constraints.\n", + "\n", + "Finally, we have stoichiometric constraints with the form `G[j, r] == n[j, r]*X[r]` where ``G`` is generation, ``X`` is extent and ``n`` is the stoichiometric coefficient (i.e., a constant) - these are simple ``A=B`` constraints, so scaling by maximum magnitude is equivalent to other methods (as there are only two terms which will take the same value, all schemes will give the same result in the end).\n", + "\n", + "So, for the equilibrium reactor at least, we are probably best leaving things as they are.\n", + "\n", + "However, there is one important test left. The whole purpose of a scaling routine is to allow us to perturb the model and solve it at the new state so we should test to confirm that our new ``Scaler`` has improved the performance of our solver when solving for the perturbed state we tried earlier. This also lets us see how the new ``Scaler`` will look for a user trying to apply the tool, which we can see below." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Ipopt 3.13.2: linear_solver=ma57\n", + "max_iter=200\n", + "nlp_scaling_method=gradient-based\n", + "tol=1e-06\n", + "\n", + "\n", + "******************************************************************************\n", + "This program contains Ipopt, a library for large-scale nonlinear optimization.\n", + " Ipopt is released as open source code under the Eclipse Public License (EPL).\n", + " For more information visit http://projects.coin-or.org/Ipopt\n", + "\n", + "This version of Ipopt was compiled from source code available at\n", + " https://github.com/IDAES/Ipopt as part of the Institute for the Design of\n", + " Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE\n", + " Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.\n", + "\n", + "This version of Ipopt was compiled using HSL, a collection of Fortran codes\n", + " for large-scale scientific computation. All technical papers, sales and\n", + " publicity material resulting from use of the HSL codes within IPOPT must\n", + " contain the following acknowledgement:\n", + " HSL, a collection of Fortran codes for large-scale scientific\n", + " computation. See http://www.hsl.rl.ac.uk.\n", + "******************************************************************************\n", + "\n", + "This is Ipopt version 3.13.2, running with linear solver ma57.\n", + "\n", + "Number of nonzeros in equality constraint Jacobian...: 21\n", + "Number of nonzeros in inequality constraint Jacobian.: 0\n", + "Number of nonzeros in Lagrangian Hessian.............: 9\n", + "\n", + "Total number of variables............................: 8\n", + " variables with only lower bounds: 5\n", + " variables with lower and upper bounds: 1\n", + " variables with only upper bounds: 0\n", + "Total number of equality constraints.................: 8\n", + "Total number of inequality constraints...............: 0\n", + " inequality constraints with only lower bounds: 0\n", + " inequality constraints with lower and upper bounds: 0\n", + " inequality constraints with only upper bounds: 0\n", + "\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 0 0.0000000e+00 5.53e+02 1.00e+00 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0\n", + "Reallocating memory for MA57: lfact (247)\n", + " 1 0.0000000e+00 5.53e+02 1.20e+00 -1.0 9.95e+02 - 2.00e-05 1.96e-05h 1\n", + " 2 0.0000000e+00 5.53e+02 1.20e+00 -1.0 9.57e+02 - 2.06e-05 2.00e-05h 1\n", + " 3 0.0000000e+00 5.53e+02 7.36e+01 -1.0 9.25e+02 - 4.36e-04 4.06e-05h 1\n", + " 4 0.0000000e+00 5.53e+02 3.34e+05 -1.0 8.55e+02 - 2.41e-04 1.21e-03f 1\n", + " 5 0.0000000e+00 5.40e+02 6.59e+03 -1.0 9.98e+01 - 2.25e-04 2.34e-02f 1\n", + " 6 0.0000000e+00 5.24e+02 1.11e+08 -1.0 9.74e+01 - 2.54e-02 2.84e-02f 1\n", + " 7 0.0000000e+00 2.36e+02 2.03e+06 -1.0 9.47e+01 - 7.09e-02 5.49e-01h 1\n", + " 8 0.0000000e+00 8.62e+01 6.37e+10 -1.0 4.27e+01 - 8.23e-03 6.35e-01h 1\n", + " 9 0.0000000e+00 1.96e+00 4.93e+10 -1.0 1.56e+01 - 1.00e+00 1.00e+00h 1\n", + "iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls\n", + " 10 0.0000000e+00 2.05e-04 2.15e+09 -1.0 3.70e-02 - 1.00e+00 1.00e+00h 1\n", + " 11 0.0000000e+00 6.28e-15 2.56e+05 -1.0 2.05e-04 - 1.00e+00 1.00e+00h 1\n", + "\n", + "Number of Iterations....: 11\n", + "\n", + " (scaled) (unscaled)\n", + "Objective...............: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Dual infeasibility......: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Constraint violation....: 6.2780236478193237e-15 6.2780236478193237e-15\n", + "Complementarity.........: 0.0000000000000000e+00 0.0000000000000000e+00\n", + "Overall NLP error.......: 6.2780236478193237e-15 6.2780236478193237e-15\n", + "\n", + "\n", + "Number of objective function evaluations = 12\n", + "Number of objective gradient evaluations = 12\n", + "Number of equality constraint evaluations = 12\n", + "Number of inequality constraint evaluations = 0\n", + "Number of equality constraint Jacobian evaluations = 12\n", + "Number of inequality constraint Jacobian evaluations = 0\n", + "Number of Lagrangian Hessian evaluations = 11\n", + "Total CPU secs in IPOPT (w/o function evaluations) = 0.000\n", + "Total CPU secs in NLP function evaluations = 0.000\n", + "\n", + "EXIT: Optimal Solution Found.\n" + ] + } + ], + "source": [ + "m = build_model()\n", + "\n", + "scaler = EquilibriumReactorScaler()\n", + "scaler.scale_model(m.fs.equil)\n", + "\n", + "perturb_model(m)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We can see that by applying our new ``EquilibriumReactorScaler`` we are now able to use IPOPT to solve for the perturbation, and that it reaches an optimal solution in 11 iterations. Looking at the solver logs we can see that the solver step lengths (``alpha_du`` and ``alpha_pr``) are rather small for the first iterations but the number of line searches (``ls``) is 1 for all iterations. This indicates that IPOPT is pushing up against some bound or constraint and cannot make full steps, but in this case it is due to the fact that to achieve equilibrium for an irreversible reaction at least one concentration must be driven to zero (and is why an EquibriumReactor is probably not a good choice for this test case). However, the fact that our ``Scaler`` let us solve for this challenging test case is probably a good sign.\n", + "\n", + "\n", + "## Step 8: Finishing Up\n", + "\n", + "Ideally, we would have more than one test case to apply our ``Scaler`` to put it through its paces and ensure it is robust across a wide range of conditions. However, for the purposes of this workshop we will move on.\n", + "\n", + "Once you are satisfied that your ``Scaler`` is ready, you can start applying it to actual problems of interest. For those modelers developing new unit and property models, you should assign your new ``Scaler`` as the default scaler for that unit model. You can do this by setting the ``default_scaler`` attribute on your model to point to the new ``Scaler`` as shown below.\n", + "\n", + "```python\n", + "@declare_process_block_class(\"EquilibriumReactor\")\n", + "class EquilibriumReactorData(UnitModelBlockData):\n", + " \"\"\"\n", + " Standard Equilibrium Reactor Unit Model Class\n", + " \"\"\"\n", + "\n", + " # Setting the default_scaler attribute\n", + " default_scaler = EquilibriumReactorScaler\n", + "```\n", + "\n", + "With that, we have finished this workshop on developing ``Scaler`` classes. Hopefully you now know enough to begin writing ``Scalers`` for your own models, and have gained some insight into how to think about developing scaling routines and the tools available to help you." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "celltoolbar": "Tags", + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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.11.5" + } + }, + "nbformat": 4, + "nbformat_minor": 3 +} \ No newline at end of file