4. Amundson_1958¶
This code follows the approach that was published by Amundson and Pontinen [Amundson1958].
- Amundson1958
Amundson, NR and Pontinen, AJ. Multicomponent Distillation Calculations on a Large Digital Computer. Ind. Eng. Chem. 1958;50:730–736.
This approach takes advantage of the following mathematical trick.
4.1. Mathematical Trick¶
A system of equations such as
where \(A\) is a matrix of constants, \(x\) is a vector of unknowns, and \(b\) is a vector of constants, can be solved very efficiently if the matrix \(A\) can be made into the following form:
This special type of matrix is called a tridiagonal_matrix.
It can be solved, for example, if we define the following vectors
or, with python code, like
l = [l_0, l_1, l_2, l_3]
d = [d_0, d_1, d_2, d_3, d_4]
u = [u_0, u_1, u_2, u_3]
b = [b_0, b_1, b_2, b_3]
the \(5\times5\) matrix can be solved efficiently using the following code:
from scipy.sparse import linalg, diags
A = diags(
diagonals=[l, d, u],
offsets=[-1, 0, 1],
shape=(5, 5),
)
x = linalg.spsolve(A, b)
This mathematical trick is used throughout the solution algorithm.
4.3. Tutorial¶
4.3.1. Step 1, Input¶
In Step 1, the property data and design variables are input. The property data is described in Equilibrium Properties. A model with this property data and input design variables is created by the following code:
>>> from distillation.amundson_1958 import Model
>>> model = Model(
components=['n-Butane', 'n-Pentane'],
F=1000., # feed flow rate [kmol/h]
P=101325.*2, # pressure (constant) [Pa]
z_feed=[0.45, 0.55], # mole fraction n-Butane = 0.45
RR=1., # reflux ratio [L/D]
D=400., # distillate flow rate [kmol/h]
N=3, # number of equilibrium contacts
feed_stage=2, # stage at which feed goes in
)
Currently, the input design flow rates must be
specified in kmol/h and the pressure must be specified in Pa.
The method called in this process is
distillation.amundson_1958.main.Model.__init__()
(see Class Method Reference).
4.3.2. Step 2, Make Initial Guess¶
Before beginning the solution procedure, we need to have an initial guess for the liquid flow rates, vapor flow rates, and temperatures on each stage \(j\). That is, we need to generate initial guesses for \(L_j\), \(V_j\), and \(T_j\).
First we calculate the feed temperature (which is a saturated liquid) using a bubble point calculation (see Bubble Point). We set the temperatures \(T_j\) to be the same as the feed temperature. This step is performed automatically when the model is initialized with input parameters. We can check to see the feed temperature calculated for our model by the following
>>> model.T_feed
306.37018410667076
Then, we generate the initial guesses for these values by assuming constant molal overflow (the Lewis method). Computationally, we can do this using the following
>>> model.generate_initial_guess()
We can then check the arrays of liquid flow rates (\(L\)) and vapor flow rates (\(V\)) obtained by the calculations as below
>>> model.L
array([ 400., 400., 1400., 600.])
>>> model.V
array([ 0., 800., 800., 800.])
or, looking at the liquid flow rates by stage more specifically, .. code-block:: python
>>> for stage, L_j in enumerate(model.L):
... print('L_j', stage, L_j)
...
L_j 0 400.0
L_j 1 400.0
L_j 2 1400.0
L_j 3 600.0
Here, we see that the liquid flow rates in the enriching section are \((L/D)\times D=1\times 400=400=L\). Since the feed stage is stage 2, we notice that the liquid leaving this stage is equal to \(\overline{L}=L + F=400 + 1000\). Finally, we notice that the liquid leaving the bottom stage, the partial reboiler, is the same as the bottoms flow rate that would be calculated from an overall balance, \(B = F - D = 1000 - 400 = 600\).
>>> for stage, V_j in enumerate(model.V):
... print('V_j', stage, V_j)
...
V_j 0 0.0
V_j 1 800.0
V_j 2 800.0
V_j 3 800.0
We note that there is no vapor leaving stage 0 because the
column is equipped with a total condensor.
We note that V[1] = model.RR*model.D + model.D
,
which arises from the mass balance at the top of the column.
Similarly, we also realize that
V[3] = model.L[2] - model.B
.
4.3.3. Step 3, Update \(K\)-values¶
In Step 3, we calculate \(K_{i,j}\), the \(K\)-value for each component \(i\) on stage \(j\). We can do this now for our model by the following:
>>> model.update_K_values()
We can think of this step as doing something similar to the following:
for i in model.components:
for j in range(model.N + 1):
model.K[i][j] = K_func(i, T[j], model.P_feed)
where the K_func
function returns the \(K\)-value for component
\(i\) on stage \(j\).
The for
loops iteratively store the \(K\)-values in each component of the amtrix.
In our example, we can checkout our new \(K\)-values as follows:
>>> import pprint # for pretty printing
>>> pprint.pprint(model.K)
{'n-Butane': array([1.61320297, 1.61320297, 1.61320297, 1.61320297]),
'n-Pentane': array([0.49828848, 0.49828848, 0.49828848, 0.49828848])}
As expected, the values for butane are higher for the light component, butane. The values are also the same for all stages because the temperature of all stages are currently fixed at the feed temperature.
Behind the scenes, we then store the current stage temperatures
in an attribute T_old
which
we will need later in Step 6, T_j Convergence Check to determine whether the temperature
has converged.
The code for this step is depicted in
distillation.amundson_1958.main.Model.update_K_values()
.
4.3.4. Step 4, Solve Component Mass Balances¶
The two stages in which tridiagonal matrices are used for computational efficiency are Step 4, Solve Component Mass Balances and Step 7, Solve Energy Balances, which are depicted in colored blue boxes.
In this step, the matrices are tridiagonal for the complete mass balances for each component (as opposed to all components together). The variables that are calculated are \(l_{ij}\) for each component \(i\) and each stage \(j\). This new variable is introduced where \(l_{ij}=x_iL_j\), and can be interpreted as a component flow rate. By formulating the mass balance equations into function of \(l_{ij}\) the system of equations can be converted in to a tridiagonal matrix. Computationally, we can perform this step using our model by the following:
>>> for i in model.components:
... model.solve_component_mass_bal(i)
...
>>>
First, the tridiagonal matrix is generated. Then, the efficient approach described in Mathematical Trick is used. to solve the equations.
We can see the results of the calculation by probing the model attributes as follows
>>> import pprint # pretty printing
>>> pprint.pprint(model.l)
{'n-Butane': array([288.88918652, 179.07801532, 507.65007098, 161.11081348]),
'n-Pentane': array([ 74.88288594, 150.28018773, 790.77762525, 475.11711406])}
Since \(\sum_i l_{i,j} = L_j\), it is worthwhile to compare our calculations to what was calculated for \(L_j\) with the Lewis method (i.e. assuming Constant Molal Overflow)
>>> for stage in model.stages:
... print(stage, model.L[stage], model.l['n-Butane'][stage]+model.l['n-Pentane'][stage])
...
0 400.0 363.7720724598715
1 400.0 329.3582030449366
2 1400.0 1298.4276962288486
3 600.0 636.2279275401286
>>>
From this analysis, we realize that our initial guess for the liquid and vapor flow rates was not that bad.
4.3.5. Step 5¶
In Step 2, Make Initial Guess, we performed a Bubble Point calculation to determine the temperature of the feed. We do the same thing here, except we do it multiple times (i.e., for each stage). However, we have not calculated the liquid phase mole fractions explicitly yet. We can use the liquid component mass balances calculated in Step 4, Solve Component Mass Balances to calculate the mole fractions as
and then perform the Bubble Point calculations with these mole fractions.
Computationally, we can perform this step with our model using the following
>>> model.T
array([306.37018411, 306.37018411, 306.37018411, 306.37018411])
>>> model.update_T_values()
>>> model.T
array([295.34137166 302.94861315 308.72858323 314.97039634])
And we can see how the temperatures change from the initial guess (the feed temperature) to the results of the first calculation.
4.3.6. Step 6, \(T_j\) Convergence Check¶
In this step, we determine whether the temperatures on all stages \(j\), \(T_j\), have converged. Mathematically, we require the following
The temperature tolerance \(\epsilon\)
is attribute distillation.amundson_1958.main.Model.temperature_tol
;
It is currently set to 0.01.
We can see from Step 5 that the temperature has clearly changed by more than 0.01~K between the first iteration. When we perform this step, we find that the temperature has not converged.
>>> model.T_is_converged()
False
Following the algorithmic diagram, we go back to Step 3 and perform a few more iterations.
Its convenient to perform these iterations with a while loop, as follows
while not model.T_is_converged():
... model.update_K_values()
... for i in model.components:
... model.solve_component_mass_bal(i)
... model.update_T_values()
... print(model.T)
...
[295.50131711 303.41982848 309.35773209 315.96224522]
[295.6166139 303.62102821 309.52271272 316.15309479]
[295.65094925 303.67756389 309.56508571 316.19923696]
[295.66006297 303.69238402 309.57598554 316.21097278]
[295.66242518 303.69621481 309.57879188 316.21398756]
The temperature is converged after 5 more iterations, as we can see from the output above. Since the temperature has converged, we can now proceed to the next step.
4.3.7. Step 7, Solve Energy Balances¶
In Step 7, we solve the energy balances. Here, all the balances can again be combined into one banded matrix.
For a general stage \(j\), the energy balance is
The liquid flow rates can be substituted in using the following relationship
So that the system of equations only becomes functions of \(V_j\) and \(V_{j+1}\).
Todo
Finish description of energy balance
A helper function for creation and solution of these matrices is provided in the model. In other words, the energy balances can be solved by the following code:
>>> model.L
array([ 400., 400., 1400., 600.])
>>> model.V
array([ 0., 800., 800., 800.])
>>> model.solve_energy_balances()
>>> model.L
array([ 400. , 312.3605968 , 1300.10206406, 600. ])
>>> model.V
array([ 0. , 730.64500782, 712.3605968 , 700.10206406])
>>>
Where we have added extra steps so that we can check the
values of model.L
and model.V
before
and after solving the energy balances.
4.3.8. Step 8, \(V_j\), \(L_j\) Convergence Check¶
In this step, we determine if the simulation has converged. If the following holds true for all stages \(j\)
for each variable \(X=V\) and \(X=L\).
The flow rate tolerance, \(\epsilon\),
is found in the attribute distillation.amundson_1958.main.Model.flow_rate_tol
.
The code for this step is found in distillation.amundson_1958.main.Model.flow_rates_converged()
Obviously, our flow rates have not converged after just one iteration, as we have seen that several values have changed by \(\\approx 100\) kmol/h. We will need to come up with a looping algorithm to get the simulation to converge, as described in the next section.
4.4. Putting it all together¶
So far, we can combine our code from the tutorial into the following
>>> from distillation.amundson_1958 import Model
>>> model = Model(
components=['n-Butane', 'n-Pentane'],
F=1000., # feed flow rate [kmol/h]
P=101325.*2, # pressure (constant) [Pa]
z_feed=[0.45, 0.55], # mole fraction n-Butane = 0.45
RR=1., # reflux ratio [L/D]
D=400., # distillate flow rate [kmol/h]
N=3, # number of equilibrium contacts
feed_stage=2, # stage at which feed goes in
)
>>> model.generate_initial_guess()
>>> while not model.T_is_converged():
... model.update_K_values()
... for i in model.components:
... model.solve_component_mass_bal(i)
... model.update_T_values()
...
>>> model.solve_energy_balances()
But we need to figure out how to do the last iteration to automatically try to converge.
It turns out, we can do this with a nested while
loop,
as shown below
>>> while not model.flow_rates_converged():
... for i in self.components:
... model.solve_component_mass_bal(i)
... model.update_T_values()
... while not model.T_is_converged():
... model.update_K_values()
... for i in model.components:
... model.solve_component_mass_bal(i)
... model.update_T_values()
... model.solve_energy_balances()
...
This is what happens when the user invokes model.run()
For our example, it turns out that it takes about 4 loops
through the step 4-8 loop, and then we will be converged.
The final results for the flow rates can be calculated as
>>> model.L
array([ 400. , 353.80944637, 1340.92773179, 600. ])
>>> model.V
array([ 0. , 733.53885641, 753.80944637, 740.92773179])
which is still quite similar to what we calculated by the Lewis method. We can also take a look at the stage temperatures and mole fractions as below
>>> model.T
array([295.60405689, 303.58875055, 309.37291267, 315.8254746 ])
>>> list(model.x_ij_expr('n-Butane', i) for i in model.stages)
[0.7648435805973592, 0.5587863474554224, 0.38180690272177853, 0.24010427960176078]
>>> list(model.y_ij_expr('n-Butane', i) for i in model.stages)
[0.902879383384724, 0.8342045394354831, 0.6681268674620511, 0.4965317369291433]
As expected, the distillate is greatly enriched in the light component (n-Butane).
4.5. Class Method Reference¶
-
class
distillation.amundson_1958.main.
Model
(components: list = None, F: float = 0.0, P: float = 101325.0, z_feed: list = None, RR: float = 1, D: float = 0, N: int = 1, feed_stage: int = 0, T_feed_guess: float = 300.0)[source]¶ -
H_j_rule
(stage)[source]¶ Enthalpy of vapor on stage j. Calculated for ideal mixture
\[H_j = \sum_i y_{ij}H^*_i(T_j)\]where the asterisk indicates the pure component enthalpy
Todo
convert y mole fractions to dynamic expression
- Returns
\(H_j\) [J/kmol]
-
Q_condenser_rule
()[source]¶ Condenser requirement can be determined from balances around total condenser
-
T_is_converged
()[source]¶ In this step, we determine whether the temperatures on all stages \(j\), \(T_j\), have converged. Mathematically, we require the following
\[\sqrt{\left(T_{j,\mathrm{new}} - T_{j,\mathrm{old}}\right)^2} < \epsilon\]The temperature tolerance \(\epsilon\) is attribute
distillation.amundson_1958.main.Model.temperature_tol
; It is currently set to 0.01.- Returns
True if T is converged, else False
-
flow_rates_converged
()[source]¶ Determine if flow rates are converged
Use the mathematical criterion in
Model.is_below_relative_error()
-
generate_initial_guess
()[source]¶ Before beginning the solution procedure, we need to have an initial guess for the liquid flow rates, vapor flow rates, and temperatures on each stage \(j\). That is, we need to generate initial guesses for \(L_j\), \(V_j\), and \(T_j\).
First we calculate the feed temperature (which is a saturated liquid) using a bubble point calculation (see Bubble Point). We set the temperatures \(T_j\) to be the same as the feed temperature. This step is performed automatically when the model is initialized with input parameters. We can check to see the feed temperature calculated for our model by the following
>>> model.T_feed 306.37018410667076
Then, we generate the initial guesses for these values by assuming constant molal overflow (the Lewis method). Computationally, we can do this using the following
>>> model.generate_initial_guess()
We can then check the arrays of liquid flow rates (\(L\)) and vapor flow rates (\(V\)) obtained by the calculations as below
>>> model.L array([ 400., 400., 1400., 600.]) >>> model.V array([ 0., 800., 800., 800.])
or, looking at the liquid flow rates by stage more specifically, .. code-block:: python
>>> for stage, L_j in enumerate(model.L): ... print('L_j', stage, L_j) ... L_j 0 400.0 L_j 1 400.0 L_j 2 1400.0 L_j 3 600.0
Here, we see that the liquid flow rates in the enriching section are \((L/D)\times D=1\times 400=400=L\). Since the feed stage is stage 2, we notice that the liquid leaving this stage is equal to \(\overline{L}=L + F=400 + 1000\). Finally, we notice that the liquid leaving the bottom stage, the partial reboiler, is the same as the bottoms flow rate that would be calculated from an overall balance, \(B = F - D = 1000 - 400 = 600\).
>>> for stage, V_j in enumerate(model.V): ... print('V_j', stage, V_j) ... V_j 0 0.0 V_j 1 800.0 V_j 2 800.0 V_j 3 800.0
We note that there is no vapor leaving stage 0 because the column is equipped with a total condensor. We note that
V[1] = model.RR*model.D + model.D
, which arises from the mass balance at the top of the column. Similarly, we also realize thatV[3] = model.L[2] - model.B
.
-
h_feed_rule
(stage)[source]¶ Enthalpy of liquid in feed mixture Calculated for ideal mixture
\[h = \sum_i x_{ij}h^*_i(T_j)\]where the asterisk indicates the pure component enthalpy
- Returns
\(h\) [J/kmol]
-
h_j_rule
(stage)[source]¶ Enthalpy of liquid on stage j. Calculated for ideal mixture
\[h_j = \sum_i x_{ij}h^*_i(T_j)\]where the asterisk indicates the pure component enthalpy
- Returns
\(h_j\) [J/kmol]
-
is_below_relative_error
(new, old)[source]¶ Determine relative error between two vectors
\[\sqrt{\left(\frac{X_{\mathrm{new}} - X_{\mathrm{old}}}{X_{\mathrm{new}}}\right)^2} < \epsilon\]The flow rate tolerance, \(\epsilon\), is found in the attribute
distillation.amundson_1958.main.Model.flow_rate_tol
- Parameters
new –
old –
- Return type
bool
-
update_K_values
()[source]¶ In Step 3, we calculate \(K_{i,j}\), the \(K\)-value for each component \(i\) on stage \(j\). We can do this now for our model by the following:
>>> model.update_K_values()
We can think of this step as doing something similar to the following:
for i in model.components: for j in range(model.N + 1): model.K[i][j] = K_func(i, T[j], model.P_feed)
where the
K_func
function returns the \(K\)-value for component \(i\) on stage \(j\). Thefor
loops iteratively store the \(K\)-values in each component of the amtrix.In our example, we can checkout our new \(K\)-values as follows:
>>> import pprint # for pretty printing >>> pprint.pprint(model.K) {'n-Butane': array([1.61320297, 1.61320297, 1.61320297, 1.61320297]), 'n-Pentane': array([0.49828848, 0.49828848, 0.49828848, 0.49828848])}
As expected, the values for butane are higher for the light component, butane. The values are also the same for all stages because the temperature of all stages are currently fixed at the feed temperature.
Behind the scenes, we then store the current stage temperatures in an attribute
T_old
which we will need later in Step 6, T_j Convergence Check to determine whether the temperature has converged.The code for this step is depicted in
distillation.amundson_1958.main.Model.update_K_values()
.
-
update_T_values
()[source]¶ Update temperatures in all stages by performing bubble point calculation
Todo
vectorize with matrix multiplication
-