Hide code cell content
###############################################################################
# The Institute for the Design of Advanced Energy Systems Integrated Platform
# Framework (IDAES IP) was produced under the DOE Institute for the
# Design of Advanced Energy Systems (IDAES).
#
# Copyright (c) 2018-2023 by the software owners: The Regents of the
# University of California, through Lawrence Berkeley National Laboratory,
# National Technology & Engineering Solutions of Sandia, LLC, Carnegie Mellon
# University, West Virginia University Research Corporation, et al.
# All rights reserved.  Please see the files COPYRIGHT.md and LICENSE.md
# for full copyright and license information.
###############################################################################

Flowsheet Plug Flow Reactor (PFR) Simulation and Optimization of Ethylene Glycol Production#

Author: Andrew Lee
Maintainer: Andrew Lee

Learning Outcomes#

  • Call and implement the IDAES PFR unit model

  • Construct a steady-state flowsheet using the IDAES unit model library

  • Connecting unit models in a flowsheet using Arcs

  • Fomulate and solve an optimization problem

    • Defining an objective function

    • Setting variable bounds

    • Adding additional constraints

Problem Statement#

Following the previous example implementing a Continuous Stirred Tank Reactor (CSTR) unit model, we can alter the flowsheet to use a plug flow reactor (PFR). As before, this example is adapted from Fogler, H.S., Elements of Chemical Reaction Engineering 5th ed., 2016, Prentice Hall, p. 157-160 with the following chemical reaction, property packages and flowsheet. Unlike a CSTR which assumes well-mixed liquid behavior, the concentration profiles will vary spatially in one dimension. In actuality, following start-up flow reactor exhibit dynamic behavior as they approach a steady-state equilibrium; we will assume our system has already achieved steady-state behavior. The state variables chosen for the property package are molar flows of each component by phase in each stream, temperature of each stream and pressure of each stream. The components considered are: ethylene oxide, water, sulfuric acid and ethylene glycol and the process occurs in liquid phase only. Therefore, every stream has 4 flow variables, 1 temperature and 1 pressure variable.

Chemical reaction:

C2H4O + H2O + H2SO4 → C2H6O2 + H2SO4

Property Packages:

  • egprod_ideal.py

  • egprod_reaction.py

Flowsheet

Importing Required Pyomo and IDAES components#

To construct a flowsheet, we will need several components from the Pyomo and IDAES packages. Let us first import the following components from Pyomo:

  • Constraint (to write constraints)

  • Var (to declare variables)

  • ConcreteModel (to create the concrete model object)

  • Expression (to evaluate values as a function of variables defined in the model)

  • Objective (to define an objective function for optimization)

  • TransformationFactory (to apply certain transformations)

  • Arc (to connect two unit models)

For further details on these components, please refer to the pyomo documentation: https://pyomo.readthedocs.io/en/stable/

From idaes, we will be needing the FlowsheetBlock and the following unit models:

  • Mixer

  • Heater

  • PFR

We will also be needing some utility tools to put together the flowsheet and calculate the degrees of freedom, tools for model expressions and calling variable values, and built-in functions to define property packages, add unit containers to objects and define our initialization scheme.

from pyomo.environ import (
    Constraint,
    Var,
    ConcreteModel,
    Expression,
    Objective,
    TransformationFactory,
    value,
    units as pyunits,
)
from pyomo.network import Arc

from idaes.core import FlowsheetBlock
from idaes.models.properties.modular_properties import (
    GenericParameterBlock,
    GenericReactionParameterBlock,
)
from idaes.models.unit_models import Feed, Mixer, Heater, PFR, Product

from idaes.core.solvers import get_solver
from idaes.core.util.model_statistics import degrees_of_freedom
from idaes.core.util.initialization import propagate_state

Importing Required Thermophysical and Reaction Packages#

The final step is to import the thermophysical and reaction packages. We have created a custom thermophysical package that support ideal vapor and liquid behavior for this system, and in this case we will restrict it to ideal liquid behavior only.

The reaction package here assumes Arrhenius kinetic behavior for the PFR, for which \(k_0\) and \(E_a\) are known a priori (if unknown, they may be obtained using one of the parameter estimation tools within IDAES).

\( r = -kVC_{EO} \), \( k = k_0 e^{(-E_a/RT)}\), with the variables as follows:

\(r\) - reaction rate extent in moles of ethylene oxide consumed per second; note that the traditional reaction rate would be given by \(rate = r/V\) in moles per \(m^3\) per second
\(k\) - reaction rate constant per second
\(V\) - volume of PFR in \(m^3\), note that this is liquid volume and not the total volume of the reactor itself
\(C_{EO}\) - bulk concentration of ethylene oxide in moles per \(m^3\) (the limiting reagent, since we assume excess catalyst and water)
\(k_0\) - pre-exponential Arrhenius factor per second
\(E_a\) - reaction activation energy in kJ per mole of ethylene oxide consumed
\(R\) - gas constant in J/mol-K
\(T\) - reactor temperature in K

These calculations are contained within the property, reaction and unit model packages, and do not need to be entered into the flowsheet. More information on property estimation may be found in the IDAES documentation on Parameter Estimation.

Let us import the following modules from the same directory as this Jupyter notebook:

  • egprod_ideal as thermo_props

  • egprod_reaction as reaction_props

import egprod_ideal as thermo_props
import egprod_reaction as reaction_props

Constructing the Flowsheet#

We have now imported all the components, unit models, and property modules we need to construct a flowsheet. Let us create a ConcreteModel and add the flowsheet block.

m = ConcreteModel()
m.fs = FlowsheetBlock(dynamic=False)

We now need to add the property packages to the flowsheet. Unlike the basic Flash unit model example, where we only had a thermophysical property package, for this flowsheet we will also need to add a reaction property package. We will use the Modular Property Framework and Modular Reaction Framework. The get_prop method for the natural gas property module automatically returns the correct dictionary using a component list argument. The GenericParameterBlock and GenericReactionParameterBlock methods build states blocks from passed parameter data; the reaction block unpacks using **reaction_props.config_dict to allow for optional or empty keyword arguments:

m.fs.thermo_params = GenericParameterBlock(**thermo_props.config_dict)
m.fs.reaction_params = GenericReactionParameterBlock(
    property_package=m.fs.thermo_params, **reaction_props.config_dict
)

Adding Unit Models#

Let us start adding the unit models we have imported to the flowsheet. Here, we are adding a Mixer, a Heater and a PFR. Note that all unit models need to be given a property package argument. In addition to that, there are several arguments depending on the unit model, please refer to the documentation for more details on IDAES Unit Models. For example, the Mixer is given a list consisting of names to the two inlets.

m.fs.OXIDE = Feed(property_package=m.fs.thermo_params)
m.fs.ACID = Feed(property_package=m.fs.thermo_params)
m.fs.PROD = Product(property_package=m.fs.thermo_params)
m.fs.M101 = Mixer(
    property_package=m.fs.thermo_params, inlet_list=["reagent_feed", "catalyst_feed"]
)
m.fs.H101 = Heater(
    property_package=m.fs.thermo_params,
    has_pressure_change=False,
    has_phase_equilibrium=False,
)
m.fs.R101 = PFR(
    property_package=m.fs.thermo_params,
    reaction_package=m.fs.reaction_params,
    has_equilibrium_reactions=False,
    has_heat_of_reaction=True,
    has_heat_transfer=True,
    has_pressure_change=False,
    transformation_method="dae.finite_difference",
    transformation_scheme="BACKWARD",
    finite_elements=20,
)

Connecting Unit Models Using Arcs#

We have now added all the unit models we need to the flowsheet. However, we have not yet specified how the units are to be connected. To do this, we will be using the Arc which is a pyomo component that takes in two arguments: source and destination. Let us connect the outlet of the Mixer to the inlet of the Heater, and the outlet of the Heater to the inlet of the PFR. Additionally, we will connect the Feed and Product blocks to the flowsheet:

m.fs.s01 = Arc(source=m.fs.OXIDE.outlet, destination=m.fs.M101.reagent_feed)
m.fs.s02 = Arc(source=m.fs.ACID.outlet, destination=m.fs.M101.catalyst_feed)
m.fs.s03 = Arc(source=m.fs.M101.outlet, destination=m.fs.H101.inlet)
m.fs.s04 = Arc(source=m.fs.H101.outlet, destination=m.fs.R101.inlet)
m.fs.s05 = Arc(source=m.fs.R101.outlet, destination=m.fs.PROD.inlet)

We have now connected the unit model block using the arcs. However, we also need to link the state variables on connected ports. Pyomo provides a convenient method TransformationFactory to write these equality constraints for us between two ports:

TransformationFactory("network.expand_arcs").apply_to(m)

Adding Expressions to Compute Operating Costs#

In this section, we will add a few Expressions that allows us to evaluate the performance. Expressions provide a convenient way of calculating certain values that are a function of the variables defined in the model. For more details on Expressions, please refer to the Pyomo Expression documentation.

For this flowsheet, we are interested in computing ethylene glycol production in millions of pounds per year, as well as the total costs due to cooling and heating utilities.

Let us first add an Expression to convert the product flow from mol/s to MM lb/year of ethylene glycol. We see that the molecular weight exists in the thermophysical property package, so we may use that value for our calculations.

m.fs.eg_prod = Expression(
    expr=pyunits.convert(
        m.fs.PROD.inlet.flow_mol_phase_comp[0, "Liq", "ethylene_glycol"]
        * m.fs.thermo_params.ethylene_glycol.mw,  # MW defined in properties as kg/mol
        to_units=pyunits.Mlb / pyunits.yr,
    )
)  # converting kg/s to MM lb/year

Now, let us add expressions to compute the reactor cooling cost (\\(/s) assuming a cost of 2.12E-5 \\\)/kW, and the heating utility cost (\\(/s) assuming 2.2E-4 \\\)/kW. To calculate cooling cost, it is important to note that the heat duty is not constant throughout the reactor’s length and is expressed in terms of heat per length (J/m/s). This is why we utilize the trapezoid rule to calculate the total heat duty of the reactor:\(Q=\Delta x\big(\sum_{k=1}^{N-1}(Q_k)+\frac{Q_N+Q_0}{2}\big)\) where k is the subinterval in the length domain, N is the number of intervals, and \(\Delta x\) is the length of the interval. Note that the heat duty is in units of watt (J/s). The total operating cost will be the sum of the two, expressed in \$/year, assuming 8000 operating hours per year (~10% downtime, which is fairly common for small scale chemical plants):

m.fs.cooling_cost = Expression(
    expr=2.12e-8
    * m.fs.R101.length
    / m.fs.R101.config.finite_elements
    * (
        -sum(
            m.fs.R101.heat_duty[0, k]
            for k in m.fs.R101.control_volume.length_domain
            if 0.0 <= k < 1.0
        )
    )
    - (
        value(m.fs.R101.heat_duty[0, m.fs.R101.control_volume.length_domain.at(1)])
        - value(m.fs.R101.heat_duty[0, m.fs.R101.control_volume.length_domain.at(-1)])
    )
    / 2
)  # the reaction is exothermic, so R101 duty is negative
m.fs.heating_cost = Expression(
    expr=2.2e-7 * m.fs.H101.heat_duty[0]
)  # the stream must be heated to T_rxn, so H101 duty is positive
m.fs.operating_cost = Expression(
    expr=(3600 * 8000 * (m.fs.heating_cost + m.fs.cooling_cost))
)

Fixing Feed Conditions#

Let us first check how many degrees of freedom exist for this flowsheet using the degrees_of_freedom tool we imported earlier. We expect each stream to have 6 degrees of freedom, the mixer to have 0 (after both streams are accounted for), the heater to have 1 (just the duty, since the inlet is also the outlet of M101), and the reactor to have 2 unit specifications and 1 specification for each finite element. Therefore, we have 35 degrees of freedom to specify: temperature, pressure and flow of all four components on both streams; outlet heater temperature; a reactor property such as conversion or heat duty at each finite element; reactor volume and reactor length.

print(degrees_of_freedom(m))
35

We will now be fixing the feed stream to the conditions shown in the flowsheet above. As mentioned in other tutorials, the IDAES framework expects a time index value for every referenced internal stream or unit variable, even in steady-state systems with a single time point \( t = 0 \) (t = [0] is the default when creating a FlowsheetBlock without passing a time_set argument). The non-present components in each stream are assigned a very small non-zero value to help with convergence and initializing. Based on stoichiometric ratios for the reaction, 80% conversion and 200 MM lb/year (46.4 mol/s) of ethylene glycol, we will initialize our simulation with the following calculated values:

m.fs.OXIDE.outlet.flow_mol_phase_comp[0, "Liq", "ethylene_oxide"].fix(
    58.0 * pyunits.mol / pyunits.s
)
m.fs.OXIDE.outlet.flow_mol_phase_comp[0, "Liq", "water"].fix(
    39.6 * pyunits.mol / pyunits.s
)  # calculated from 16.1 mol EO / cudm in stream
m.fs.OXIDE.outlet.flow_mol_phase_comp[0, "Liq", "sulfuric_acid"].fix(
    1e-5 * pyunits.mol / pyunits.s
)
m.fs.OXIDE.outlet.flow_mol_phase_comp[0, "Liq", "ethylene_glycol"].fix(
    1e-5 * pyunits.mol / pyunits.s
)
m.fs.OXIDE.outlet.temperature.fix(298.15 * pyunits.K)
m.fs.OXIDE.outlet.pressure.fix(1e5 * pyunits.Pa)

m.fs.ACID.outlet.flow_mol_phase_comp[0, "Liq", "ethylene_oxide"].fix(
    1e-5 * pyunits.mol / pyunits.s
)
m.fs.ACID.outlet.flow_mol_phase_comp[0, "Liq", "water"].fix(
    200 * pyunits.mol / pyunits.s
)
m.fs.ACID.outlet.flow_mol_phase_comp[0, "Liq", "sulfuric_acid"].fix(
    0.334 * pyunits.mol / pyunits.s
)  # calculated from 0.9 wt% SA in stream
m.fs.ACID.outlet.flow_mol_phase_comp[0, "Liq", "ethylene_glycol"].fix(
    1e-5 * pyunits.mol / pyunits.s
)
m.fs.ACID.outlet.temperature.fix(298.15 * pyunits.K)
m.fs.ACID.outlet.pressure.fix(1e5 * pyunits.Pa)

Fixing Unit Model Specifications#

Now that we have fixed our inlet feed conditions, we will now be fixing the operating conditions for the unit models in the flowsheet. Let us fix the outlet temperature of H101 to 328.15 K.

m.fs.H101.outlet.temperature.fix(328.15 * pyunits.K)

For the PFR, we have to define the conversion in terms of ethylene oxide. Note that the PFR reaction volume variable (m.fs.R101.volume) does not need to be defined here since it is internally defined by the PFR model. We’ll estimate 50% conversion for our initial flowsheet:

m.fs.R101.conversion = Var(
    bounds=(0, 1), initialize=0.80, units=pyunits.dimensionless
)  # fraction

m.fs.R101.conv_constraint = Constraint(
    expr=m.fs.R101.conversion
    * m.fs.R101.inlet.flow_mol_phase_comp[0, "Liq", "ethylene_oxide"]
    == (
        m.fs.R101.inlet.flow_mol_phase_comp[0, "Liq", "ethylene_oxide"]
        - m.fs.R101.outlet.flow_mol_phase_comp[0, "Liq", "ethylene_oxide"]
    )
)

for x in m.fs.R101.control_volume.length_domain:
    if x == 0:
        continue
    m.fs.R101.control_volume.properties[0, x].temperature.fix(
        328.15 * pyunits.K
    )  # equal inlet reactor temperature

m.fs.R101.conversion.fix(0.5)

m.fs.R101.length.fix(1 * pyunits.m)

As we did not place a specification on reactor duty, the solver may try positive values to increase the reaction temperature and rate. To prevent the optimization from diverging, we need to set an upper bound restricting heat flow to cooling only:

m.fs.R101.heat_duty.setub(
    0 * pyunits.J / pyunits.m / pyunits.s
)  # heat duty is only used for cooling

For initialization, we solve a square problem (degrees of freedom = 0). Let’s check the degrees of freedom below:

print(degrees_of_freedom(m))
0

Finally, we need to initialize the each unit operation in sequence to solve the flowsheet. As in best practice, unit operations are initialized or solved, and outlet properties are propagated to connected inlet streams via arc definitions as follows:

# Initialize and solve each unit operation
m.fs.OXIDE.initialize()
propagate_state(arc=m.fs.s01)

m.fs.ACID.initialize()
propagate_state(arc=m.fs.s01)

m.fs.M101.initialize()
propagate_state(arc=m.fs.s03)

m.fs.H101.initialize()
propagate_state(arc=m.fs.s04)

m.fs.R101.initialize()
propagate_state(arc=m.fs.s05)

m.fs.PROD.initialize()

# set solver
solver = get_solver()
2024-05-28 17:23:58 [INFO] idaes.init.fs.OXIDE.properties: Starting initialization
---------------------------------------------------------------------------
ApplicationError                          Traceback (most recent call last)
Cell In[18], line 2
      1 # Initialize and solve each unit operation
----> 2 m.fs.OXIDE.initialize()
      3 propagate_state(arc=m.fs.s01)
      5 m.fs.ACID.initialize()

File ~/checkouts/readthedocs.org/user_builds/idaes-examples/envs/latest/lib/python3.8/site-packages/idaes/core/base/unit_model.py:540, in UnitModelBlockData.initialize(blk, *args, **kwargs)
    537     c.deactivate()
    539 # Remember to collect flags for fixed vars
--> 540 flags = blk.initialize_build(*args, **kwargs)
    542 # If costing block exists, activate and initialize
    543 for c in init_order:

File ~/checkouts/readthedocs.org/user_builds/idaes-examples/envs/latest/lib/python3.8/site-packages/idaes/models/unit_models/feed.py:186, in FeedData.initialize_build(blk, state_args, outlvl, solver, optarg)
    183     state_args = {}
    185 # Initialize state block
--> 186 blk.properties.initialize(
    187     outlvl=outlvl, optarg=optarg, solver=solver, state_args=state_args
    188 )
    190 init_log.info("Initialization Complete.")

File ~/checkouts/readthedocs.org/user_builds/idaes-examples/envs/latest/lib/python3.8/site-packages/idaes/models/properties/modular_properties/base/generic_property.py:2036, in _GenericStateBlock.initialize(blk, state_args, state_vars_fixed, hold_state, outlvl, solver, optarg)
   2031         raise InitializationError(
   2032             f"{blk.name} Unexpected degrees of freedom during "
   2033             f"initialization at property initialization step: {dof}."
   2034         )
   2035     with idaeslog.solver_log(solve_log, idaeslog.DEBUG) as slc:
-> 2036         res = solve_indexed_blocks(opt, [blk], tee=slc.tee)
   2037     init_log.info(
   2038         "Property initialization: {}.".format(idaeslog.condition(res))
   2039     )
   2041 # ---------------------------------------------------------------------
   2042 # Return constraints to initial state

File ~/checkouts/readthedocs.org/user_builds/idaes-examples/envs/latest/lib/python3.8/site-packages/idaes/core/util/initialization.py:285, in solve_indexed_blocks(solver, blocks, **kwds)
    278     tmp._ctypes[Block] = [  # pylint: disable=protected-access
    279         0,
    280         nBlocks - 1,
    281         nBlocks,
    282     ]
    284     # Solve temporary Block
--> 285     results = solver.solve(tmp, **kwds)
    287 finally:
    288     # Clean up temporary Block contents so they are not removed when Block
    289     # is garbage collected.
    290     tmp._decl = {}  # pylint: disable=protected-access

File ~/checkouts/readthedocs.org/user_builds/idaes-examples/envs/latest/lib/python3.8/site-packages/pyomo/opt/base/solvers.py:534, in OptSolver.solve(self, *args, **kwds)
    531 def solve(self, *args, **kwds):
    532     """Solve the problem"""
--> 534     self.available(exception_flag=True)
    535     #
    536     # If the inputs are models, then validate that they have been
    537     # constructed! Collect suffix names to try and import from solution.
    538     #
    539     from pyomo.core.base.block import BlockData

File ~/checkouts/readthedocs.org/user_builds/idaes-examples/envs/latest/lib/python3.8/site-packages/pyomo/opt/solver/shellcmd.py:140, in SystemCallSolver.available(self, exception_flag)
    138     if exception_flag:
    139         msg = "No executable found for solver '%s'"
--> 140         raise ApplicationError(msg % self.name)
    141     return False
    142 return True

ApplicationError: No executable found for solver 'ipopt'
# Solve the model
results = solver.solve(m, tee=True)
Ipopt 3.13.2: nlp_scaling_method=gradient-based
tol=1e-06
max_iter=200


******************************************************************************
This program contains Ipopt, a library for large-scale nonlinear optimization.
 Ipopt is released as open source code under the Eclipse Public License (EPL).
         For more information visit http://projects.coin-or.org/Ipopt

This version of Ipopt was compiled from source code available at
    https://github.com/IDAES/Ipopt as part of the Institute for the Design of
    Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE
    Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.

This version of Ipopt was compiled using HSL, a collection of Fortran codes
    for large-scale scientific computation.  All technical papers, sales and
    publicity material resulting from use of the HSL codes within IPOPT must
    contain the following acknowledgement:
        HSL, a collection of Fortran codes for large-scale scientific
        computation. See http://www.hsl.rl.ac.uk.
******************************************************************************

This is Ipopt version 3.13.2, running with linear solver ma27.

Number of nonzeros in equality constraint Jacobian...:     1923
Number of nonzeros in inequality constraint Jacobian.:        0
Number of nonzeros in Lagrangian Hessian.............:     1323

Total number of variables............................:      608
                     variables with only lower bounds:        0
                variables with lower and upper bounds:      257
                     variables with only upper bounds:       20
Total number of equality constraints.................:      608
Total number of inequality constraints...............:        0
        inequality constraints with only lower bounds:        0
   inequality constraints with lower and upper bounds:        0
        inequality constraints with only upper bounds:        0

iter    objective    inf_pr   inf_du lg(mu)  ||d||  lg(rg) alpha_du alpha_pr  ls
   0  0.0000000e+00 1.30e+06 1.00e+00  -1.0 0.00e+00    -  0.00e+00 0.00e+00   0
   1  0.0000000e+00 1.67e+07 5.52e+03  -1.0 1.17e+08    -  2.85e-01 9.90e-01f  1
   2  0.0000000e+00 2.61e+05 6.25e+03  -1.0 1.17e+06    -  8.25e-01 9.90e-01h  1
   3  0.0000000e+00 2.59e+03 3.50e+01  -1.0 1.17e+04    -  9.90e-01 9.90e-01h  1
   4  0.0000000e+00 1.97e+01 3.22e+03  -1.0 1.15e+02    -  9.90e-01 9.92e-01h  1
   5  0.0000000e+00 3.19e-07 4.81e+03  -1.0 8.77e-01    -  9.91e-01 1.00e+00h  1
Cannot recompute multipliers for feasibility problem.  Error in eq_mult_calculator

Number of Iterations....: 5

                                   (scaled)                 (unscaled)
Objective...............:   0.0000000000000000e+00    0.0000000000000000e+00
Dual infeasibility......:   1.6686898115291998e+06    1.6686898115291998e+06
Constraint violation....:   3.1874515116214752e-07    3.1874515116214752e-07
Complementarity.........:   0.0000000000000000e+00    0.0000000000000000e+00
Overall NLP error.......:   3.1874515116214752e-07    1.6686898115291998e+06


Number of objective function evaluations             = 6
Number of objective gradient evaluations             = 6
Number of equality constraint evaluations            = 6
Number of inequality constraint evaluations          = 0
Number of equality constraint Jacobian evaluations   = 6
Number of inequality constraint Jacobian evaluations = 0
Number of Lagrangian Hessian evaluations             = 5
Total CPU secs in IPOPT (w/o function evaluations)   =      0.003
Total CPU secs in NLP function evaluations           =      0.003

EXIT: Optimal Solution Found.

Analyze the Results of the Square Problem#

What is the total operating cost?

print(f"operating cost = ${value(m.fs.operating_cost)/1e6:0.3f} million per year")
operating cost = $2.082 million per year

For this operating cost, what conversion did we achieve of ethylene oxide to ethylene glycol?

m.fs.R101.report()

print()
print(f"Conversion achieved = {value(m.fs.R101.conversion):.1%}")
print()
print(
    f"Total heat duty required = "
    f"""{(value(m.fs.R101.length) / value(m.fs.R101.config.finite_elements) * 
    (value(sum(m.fs.R101.heat_duty[0, k] for k in  m.fs.R101.control_volume.length_domain if 0.0 <= k < 1.0))
    + (value(m.fs.R101.heat_duty[0, m.fs.R101.control_volume.length_domain.at(1)])
    + value(m.fs.R101.heat_duty[0, m.fs.R101.control_volume.length_domain.at(-1)]))/2))/1e6:0.3f}"""
    f" MJ"
)
print()
print(f"Tube area required = {value(m.fs.R101.area):0.3f} m^2")
print()
print(f"Tube length required = {value(m.fs.R101.length):0.3f} m")
print()
print(f"Tube volume required = {value(m.fs.R101.volume):0.3f} m^3")
====================================================================================
Unit : fs.R101                                                             Time: 0.0
------------------------------------------------------------------------------------
    Unit Performance

    Variables: 

    Key  : Value  : Units      : Fixed : Bounds
    Area : 1.1490 : meter ** 2 : False : (None, None)

------------------------------------------------------------------------------------
    Stream Table
                                                  Units         Inlet     Outlet  
    Molar Flowrate ('Liq', 'ethylene_oxide')   mole / second     58.000     29.000
    Molar Flowrate ('Liq', 'water')            mole / second     239.60     210.60
    Molar Flowrate ('Liq', 'sulfuric_acid')    mole / second    0.33401    0.33401
    Molar Flowrate ('Liq', 'ethylene_glycol')  mole / second 2.0000e-05     29.000
    Temperature                                       kelvin     328.15     328.15
    Pressure                                          pascal 1.0000e+05 1.0000e+05
====================================================================================

Conversion achieved = 50.0%

Total heat duty required = -3.469 MJ

Tube area required = 1.149 m^2

Tube length required = 1.000 m

Tube volume required = 1.149 m^3

Optimizing Ethylene Glycol Production#

Now that the flowsheet has been squared and solved, we can run a small optimization problem to minimize our production costs. Suppose we require at least 200 million pounds/year of ethylene glycol produced and 90% conversion of ethylene oxide, allowing for variable reactor volume (considering operating/non-capital costs only) and reactor temperature (heater outlet).

Let us declare our objective function for this problem.

m.fs.objective = Objective(expr=m.fs.operating_cost)

Now, we need to add the design constraints and unfix the decision variables as we had solved a square problem (degrees of freedom = 0) until now, as well as set bounds for the design variables:

m.fs.eg_prod_con = Constraint(
    expr=m.fs.eg_prod >= 200 * pyunits.Mlb / pyunits.yr
)  # MM lb/year
m.fs.R101.conversion.fix(0.90)

m.fs.R101.volume.setlb(0 * pyunits.m**3)
m.fs.R101.volume.setub(pyunits.convert(5000 * pyunits.gal, to_units=pyunits.m**3))

m.fs.R101.length.unfix()
m.fs.R101.length.setlb(0 * pyunits.m)
m.fs.R101.length.setub(5 * pyunits.m)

m.fs.H101.outlet.temperature.unfix()
m.fs.H101.outlet.temperature[0].setlb(328.15 * pyunits.K)
m.fs.H101.outlet.temperature[0].setub(
    470.45 * pyunits.K
)  # highest component boiling point (ethylene glycol)

for x in m.fs.R101.control_volume.length_domain:
    if x == 0:
        continue
    m.fs.R101.control_volume.properties[
        0, x
    ].temperature.unfix()  # allow for temperature change in each finite element

We have now defined the optimization problem and we are now ready to solve this problem.

results = solver.solve(m, tee=True)
Ipopt 3.13.2: nlp_scaling_method=gradient-based
tol=1e-06
max_iter=200
******************************************************************************
This program contains Ipopt, a library for large-scale nonlinear optimization.
 Ipopt is released as open source code under the Eclipse Public License (EPL).
         For more information visit http://projects.coin-or.org/Ipopt

This version of Ipopt was compiled from source code available at
    https://github.com/IDAES/Ipopt as part of the Institute for the Design of
    Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE
    Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.

This version of Ipopt was compiled using HSL, a collection of Fortran codes
    for large-scale scientific computation.  All technical papers, sales and
    publicity material resulting from use of the HSL codes within IPOPT must
    contain the following acknowledgement:
        HSL, a collection of Fortran codes for large-scale scientific
        computation. See http://www.hsl.rl.ac.uk.
******************************************************************************

This is Ipopt version 3.13.2, running with linear solver ma27.

Number of nonzeros in equality constraint Jacobian...:     2067
Number of nonzeros in inequality constraint Jacobian.:        1
Number of nonzeros in Lagrangian Hessian.............:     1886

Total number of variables............................:      631
                     variables with only lower bounds:        0
                variables with lower and upper bounds:      280
                     variables with only upper bounds:       21
Total number of equality constraints.................:      608
Total number of inequality constraints...............:        1
        inequality constraints with only lower bounds:        1
   inequality constraints with lower and upper bounds:        0
        inequality constraints with only upper bounds:        0

iter    objective    inf_pr   inf_du lg(mu)  ||d||  lg(rg) alpha_du alpha_pr  ls
   0  2.0817113e+06 3.66e+06 1.00e+02  -1.0 0.00e+00    -  0.00e+00 0.00e+00   0
   1  3.7272642e+06 5.24e+05 9.25e+01  -1.0 2.16e+06    -  7.82e-02 9.90e-01h  1
   2  3.7439283e+06 5.50e+03 4.53e+02  -1.0 2.14e+04    -  7.22e-01 9.91e-01h  1
   3  3.7441018e+06 2.31e-01 7.45e+03  -1.0 3.16e+02    -  9.25e-01 1.00e+00h  1
   4  3.7445196e+06 7.21e-02 1.59e+04  -1.0 3.84e+02    -  9.90e-01 1.00e+00f  1
   5  3.7434412e+06 6.03e-01 2.88e+02  -1.0 3.91e+04    -  9.82e-01 1.00e+00F  1
   6  3.4569523e+06 1.90e+05 1.52e+02  -1.0 3.75e+06    -  4.73e-01 1.00e+00F  1
   7  1.3257122e+06 6.54e+06 1.34e+02  -1.0 7.17e+07    -  1.17e-01 4.66e-01F  1
   8  5.4503925e+05 4.55e+06 4.94e+01  -1.0 4.27e+07    -  6.32e-01 3.47e-01f  1
   9  2.2925409e+05 1.16e+06 2.53e+01  -1.0 1.78e+07    -  8.45e-01 6.02e-01h  1
iter    objective    inf_pr   inf_du lg(mu)  ||d||  lg(rg) alpha_du alpha_pr  ls
  10  2.1904785e+05 1.55e+06 3.36e+01  -1.0 1.16e+07    -  1.00e+00 1.76e-01h  1
  11  2.4068883e+05 7.81e+05 4.18e+01  -1.0 4.64e+06    -  5.75e-01 5.16e-01h  1
  12  3.6492667e+05 3.58e+03 3.24e+01  -1.0 2.13e+05    -  5.89e-01 1.00e+00h  1
  13  3.3687811e+05 1.24e+03 5.19e+05  -1.7 8.40e+05    -  1.00e+00 6.11e-01h  1
  14  3.2827810e+05 6.28e+02 3.94e-02  -1.7 1.36e+05    -  1.00e+00 1.00e+00h  1
  15  3.2852907e+05 1.59e+00 1.32e-03  -1.7 5.68e+03    -  1.00e+00 1.00e+00h  1
  16  3.1973165e+05 1.33e+01 5.15e+04  -3.8 1.94e+05    -  9.86e-01 8.31e-01f  1
  17  3.1849743e+05 2.37e-01 1.46e-03  -3.8 2.16e+04    -  1.00e+00 1.00e+00h  1
  18  3.1850187e+05 5.49e-03 1.29e-07  -3.8 1.93e+02    -  1.00e+00 1.00e+00h  1
  19  3.1843083e+05 8.45e-04 3.52e-06  -5.7 1.29e+03    -  1.00e+00 1.00e+00f  1
iter    objective    inf_pr   inf_du lg(mu)  ||d||  lg(rg) alpha_du alpha_pr  ls
  20  3.1843086e+05 4.64e-07 2.07e-11  -5.7 1.29e+00    -  1.00e+00 1.00e+00h  1
  21  3.1842998e+05 3.55e-07 5.40e-10  -8.6 1.59e+01    -  1.00e+00 1.00e+00h  1

Number of Iterations....: 21

                                   (scaled)                 (unscaled)
Objective...............:   1.5329173454170544e+01    3.1842997505757213e+05
Dual infeasibility......:   5.3983226027300468e-10    1.1213831827784419e-05
Constraint violation....:   3.5460107028484344e-07    3.5460107028484344e-07
Complementarity.........:   2.5137892688647162e-09    5.2218461522255445e-05
Overall NLP error.......:   3.5460107028484344e-07    5.2218461522255445e-05


Number of objective function evaluations             = 26
Number of objective gradient evaluations             = 22
Number of equality constraint evaluations            = 26
Number of inequality constraint evaluations          = 26
Number of equality constraint Jacobian evaluations   = 22
Number of inequality constraint Jacobian evaluations = 22
Number of Lagrangian Hessian evaluations             = 21
Total CPU secs in IPOPT (w/o function evaluations)   =      0.028
Total CPU secs in NLP function evaluations           =      0.005

EXIT: Optimal Solution Found.
print(f"operating cost = ${value(m.fs.operating_cost)/1e6:0.3f} million per year")

print()
print("Heater results")

m.fs.H101.report()

print()
print("PFR reactor results")

m.fs.R101.report()
operating cost = $0.318 million per year

Heater results

====================================================================================
Unit : fs.H101                                                             Time: 0.0
------------------------------------------------------------------------------------
    Unit Performance

    Variables: 

    Key       : Value  : Units : Fixed : Bounds
    Heat Duty : 699.26 :  watt : False : (None, None)

------------------------------------------------------------------------------------
    Stream Table
                                                  Units         Inlet     Outlet  
    Molar Flowrate ('Liq', 'ethylene_oxide')   mole / second     58.000     58.000
    Molar Flowrate ('Liq', 'water')            mole / second     239.60     239.60
    Molar Flowrate ('Liq', 'sulfuric_acid')    mole / second    0.33401    0.33401
    Molar Flowrate ('Liq', 'ethylene_glycol')  mole / second 2.0000e-05 2.0000e-05
    Temperature                                       kelvin     298.15     328.15
    Pressure                                          pascal 1.0000e+05 1.0000e+05
====================================================================================

PFR reactor results

====================================================================================
Unit : fs.R101                                                             Time: 0.0
------------------------------------------------------------------------------------
    Unit Performance

    Variables: 

    Key  : Value  : Units      : Fixed : Bounds
    Area : 2.0871 : meter ** 2 : False : (None, None)

------------------------------------------------------------------------------------
    Stream Table
                                                  Units         Inlet     Outlet  
    Molar Flowrate ('Liq', 'ethylene_oxide')   mole / second     58.000     5.8000
    Molar Flowrate ('Liq', 'water')            mole / second     239.60     187.40
    Molar Flowrate ('Liq', 'sulfuric_acid')    mole / second    0.33401    0.33401
    Molar Flowrate ('Liq', 'ethylene_glycol')  mole / second 2.0000e-05     52.200
    Temperature                                       kelvin     328.15     273.15
    Pressure                                          pascal 1.0000e+05 1.0000e+05
====================================================================================

Display optimal values for the decision variables and design variables:

print("Optimal Values")
print()

print(f"H101 outlet temperature = {value(m.fs.H101.outlet.temperature[0]):0.3f} K")

print()
print(
    "Total heat duty required = ",
    f"""{(value(m.fs.R101.length) / value(m.fs.R101.config.finite_elements) * (value(sum(m.fs.R101.heat_duty[0, k] for k in  m.fs.R101.control_volume.length_domain if 0.0 <= k < 1.0))
    + (value(m.fs.R101.heat_duty[0, m.fs.R101.control_volume.length_domain.at(1)])
    + value(m.fs.R101.heat_duty[0, m.fs.R101.control_volume.length_domain.at(-1)]))/2))/1e6:0.3f}"""
    f" MJ",
)
print()
print(f"Tube area required = {value(m.fs.R101.area):0.3f} m^2")

print()
print(f"Tube length required = {value(m.fs.R101.length):0.3f} m")

print()
print(
    f"Assuming a 20% design factor for reactor volume,"
    f"total CSTR volume required = {value(1.2*m.fs.R101.volume):0.3f}"
    f" m^3 = {value(pyunits.convert(1.2*m.fs.R101.volume, to_units=pyunits.gal)):0.3f} gal"
)

print()
print(f"Ethylene glycol produced = {value(m.fs.eg_prod):0.3f} MM lb/year")

print()
print(f"Conversion achieved = {value(m.fs.R101.conversion):.1%}")
Optimal Values

H101 outlet temperature = 328.150 K

Total heat duty required =  -3.440 MJ

Tube area required = 2.087 m^2

Tube length required = 4.979 m

Assuming a 20% design factor for reactor volume,total CSTR volume required = 12.469 m^3 = 3294.093 gal

Ethylene glycol produced = 225.415 MM lb/year

Conversion achieved = 90.0%