3. An hour blitz to practical thermodynamics

3.1. Pure component chemical models

Thermosteam packages chemical and mixture thermodynamic models in a flexible framework that allows users to fully customize and extend the models, as well as create new models. Central to all thermodynamic algorithms is the Chemical object, which contains constant chemical properties, as well as thermodynamic and transport properties as a function of temperature and pressure:

[1]:
import thermosteam as tmo
# Initialize chemical with an identifier (e.g. by name, CAS, InChI...)
Water = tmo.Chemical('Water')
Water
Chemical: Water (phase_ref='l')
[Names]  CAS: 7732-18-5
         InChI: H2O/h1H2
         InChI_key: XLYOFNOQVPJJNP-U...
         common_name: water
         iupac_name: ('oxidane',)
         pubchemid: 962
         smiles: O
         formula: H2O
[Groups] Dortmund: <1H2O>
         UNIFAC: <1H2O>
         PSRK: <1H2O>
[Data]   MW: 18.015 g/mol
         Tm: 273.15 K
         Tb: 373.12 K
         Tt: 273.15 K
         Tc: 647.14 K
         Pt: 610.88 Pa
         Pc: 2.2048e+07 Pa
         Vc: 5.6e-05 m^3/mol
         Hf: -2.8582e+05 J/mol
         LHV: 44011 J/mol
         HHV: 0 J/mol
         Hfus: 6010 J/mol
         omega: 0.344
         dipole: 1.85 Debye
         similarity_variable: 0.16653
         iscyclic_aliphatic: 0
         combustion: {}

All fields can be easility accessed, for example:

[2]:
# CAS number
Water.CAS
[2]:
'7732-18-5'
[3]:
# Molecular weight (g/mol)
Water.MW
[3]:
18.01528
[4]:
# Boiling point (K)
Water.Tb
[4]:
373.124

Temperature (in Kelvin) and pressure (in Pascal) dependent properties can be computed:

[5]:
# Vapor pressure (Pa)
Water.Psat(T=373.15)
[5]:
101284.55179999319
[6]:
# Surface tension (N/m)
Water.sigma(T=298.15)
[6]:
0.07205503890847455
[7]:
# Liquid molar volume (m^3/mol)
Water.V(phase='l', T=298.15, P=101325)
[7]:
1.806920448788909e-05
[8]:
# Vapor molar volume (m^3/mol)
Water.V(phase='g', T=298.15, P=101325)
[8]:
0.023505766772305356

Temperature dependent properties are managed by indexable model handles, which contain many models ordered in decreasing priority:

[9]:
Water.Psat
TDependentModelHandle(T, P=None) -> Psat [Pa]
[0] Wagner McGraw
[1] Antoine
[2] DIPPR EQ101
[3] Wagner
[4] Boiling Critical Relation
[5] Lee Kesler
[6] Ambrose Walton
[7] Sanjari
[8] Edalat

Each model is applicable to a certain domain, as given by their Tmin and Tmax:

[10]:
Wagner_McGraw = Water.Psat[0]
Wagner_McGraw
TDependentModel(T, P=None) -> Psat [Pa]
 name: Wagner McGraw
 Tmin: 275 K
 Tmax: 647.35 K
[11]:
# Note that these attributes can be get/set too
Wagner_McGraw.Tmax, Wagner_McGraw.Tmin
[11]:
(647.35, 275)

When called, the model handle searches through each model until it finds one with an applicable domain. If none are applicable, a domain error is raised:

[12]:
Water.Psat(1000.0)
---------------------------------------------------------------------------
DomainError                               Traceback (most recent call last)
<ipython-input-12-5818a3190dca> in <module>
----> 1 Water.Psat(1000.0)

~\OneDrive\Code\thermosteam\thermosteam\base\thermo_model_handle.py in __call__(self, T, P)
    278         for model in self._models:
    279             if model.indomain(T): return model.evaluate(T)
--> 280         raise DomainError(f"{no_valid_model(self._chemical, self._var)} "
    281                          f"at T={T:.2f} K")
    282

DomainError: Water (CAS: 7732-18-5) has no valid saturated vapor pressure model at T=1000.00 K

Model handles as well as the models themselves have tabulation and plotting methods to help visualize how properties depend on temperature and pressure.

[13]:
Water.Psat.plot_vs_T([Water.Tm, Water.Tb], 'degC', 'atm', label="Water")
../_images/tutorial_An_hour_blitz_to_practical_thermodynamics_21_0.png
[14]:
# Plot all models
Water.Psat.plot_models_vs_T([Water.Tm, Water.Tb], 'degC', 'atm')
../_images/tutorial_An_hour_blitz_to_practical_thermodynamics_22_0.png
[15]:
# Plot only the 'Wagner McGraw model'
Water.Psat[0].plot_vs_T(T_units='degC', units='atm') # Bounds are the model's Tmin and Tmax
../_images/tutorial_An_hour_blitz_to_practical_thermodynamics_23_0.png

Manage the model order with the set_model_priority and move_up_model_priority methods:

[16]:
# Note: In this case, we pass the model name, but its
# also possible to pass the current index, or the model itself.
Water.Psat.move_up_model_priority('Antoine')
Water.Psat[0] # Notice how Antoine is now in the top priority
TDependentModel(T, P=None) -> Psat [Pa]
 name: Antoine
 Tmin: 273.2 K
 Tmax: 473.2 K
[17]:
Water.Psat.set_model_priority('Wagner McGraw')
Water.Psat[0] # Notice how Wagner_McGraw is back on top priority
TDependentModel(T, P=None) -> Psat [Pa]
 name: Wagner McGraw
 Tmin: 275 K
 Tmax: 647.35 K

When setting a model priority, the default priority is 0 (or top priority), but you can choose any priority:

[18]:
Water.Psat.set_model_priority('Antoine', 2)
Water.Psat[2] # Moved Antoine to priority #2
TDependentModel(T, P=None) -> Psat [Pa]
 name: Antoine
 Tmin: 273.2 K
 Tmax: 473.2 K

Thermodynamic properties dependent on the phase are handled by phase properties:

[19]:
Water.V
[19]:
<PhaseTPHandle(phase, T, P) -> V [m^3/mol]>

Phase properties contain model handles as attributes:

[20]:
Water.V.l
TPDependentModelHandle(T, P) -> V.l [m^3/mol]
[0] VDI PPDS
[1] Campbell Thodos
[2] Yen Woods
[3] Rackett
[4] Yamada Gunn
[5] Bhirud Normal
[6] Townsend Hales
[7] CRC inorganic liquid constant
[8] Rackett
[9] Costald
[10] Costald Compressed
[21]:
Water.V.g
TPDependentModelHandle(T, P) -> V.g [m^3/mol]
[0] Tsonopoulos extended
[1] Tsonopoulos
[2] Abbott
[3] Pitzer Curl
[4] CRCVirial
[5] ideal gas

A new model can be added easily to a model handle through the add_model method, for example:

[22]:
# Set top_priority=True to place model in postion [0]
@Water.Psat.add_model(Tmin=273.20, Tmax=473.20, top_priority=True)
def User_antoine_model(T):
     return 10.0**(10.116 -  1687.537 / (T - 42.98))
Water.Psat[0]
TDependentModel(T) -> Psat [Pa]
 name: User antoine model
 Tmin: 273.2 K
 Tmax: 473.2 K

The add_model method is a high level interface that even lets you create a constant model:

[23]:
Water.V.l.add_model(1.687e-05, name='User constant')
# Model is appended at the end by default
Water.V.l[-1]
ConstantThermoModel(T=None, P=None) -> V.l [m^3/mol]
 name: User constant
 value: 1.687e-05
 Tmin: 0 K
 Tmax: inf K
 Pmin: 0 Pa
 Pmax: inf Pa

Lastly, all default models in thermosteam have functors (i.e. functions with adjustable parameters):

[24]:
# The saturated vapor pressure model from before
Wagner_McGraw.evaluate
Functor: Wagner_McGraw(T, P=None) -> Psat [Pa]
 Tc: 647.35 K
 Pc: 2.2122e+07 Pa
 a: -7.7645
 b: 1.4584
 c: -2.7758
 d: -1.233
[25]:
Wagner_McGraw.evaluate.Pc = 22.064e6
Wagner_McGraw.evaluate
Functor: Wagner_McGraw(T, P=None) -> Psat [Pa]
 Tc: 647.35 K
 Pc: 2.2064e+07 Pa
 a: -7.7645
 b: 1.4584
 c: -2.7758
 d: -1.233

3.1.1. Managing chemical sets

Define multiple chemicals as a Chemicals object:

[26]:
chemicals = tmo.Chemicals(['Water', 'Ethanol'])
chemicals
[26]:
Chemicals([Water, Ethanol])

The chemicals are attributes:

[27]:
(chemicals.Water, chemicals.Ethanol)
[27]:
(Chemical('Water'), Chemical('Ethanol'))

Chemicals are indexable:

[28]:
Water = chemicals['Water']
print(repr(Water))
Chemical('Water')
[29]:
chemicals['Ethanol', 'Water']
[29]:
[Chemical('Ethanol'), Chemical('Water')]

Chemicals are also iterable:

[30]:
for chemical in chemicals:
    print(repr(chemical))
Chemical('Water')
Chemical('Ethanol')

More chemicals can also be appended:

[31]:
Propanol = tmo.Chemical('Propanol')
chemicals.append(Propanol)
chemicals
[31]:
Chemicals([Water, Ethanol, Propanol])

The main benefit of using a Chemicals object, is that they can be compiled and used as part of a thermodynamic property package, as defined through a Thermo object:

[32]:
# A Thermo object is built with an iterable of Chemicals or their IDs.
# Default mixture, thermodynamic equilibrium models are selected.
thermo = tmo.Thermo(chemicals)
thermo
Thermo(
    chemicals=CompiledChemicals([Water, Ethanol, Propanol]),
    mixture=Mixture(
        rule='ideal mixing', ...
        rigorous_energy_balance=True,
        include_excess_energies=False
    ),
    Gamma=DortmundActivityCoefficients,
    Phi=IdealFugacityCoefficients,
    PCF=IdealPoyintingCorrectionFactors
)

Creating a thermo property package, may be a little challenging if some chemicals cannot be found in the database, in which case they can be built from scratch. A complete example on how this can be done is available in another tutorial.

3.2. Material and energy balance

A Stream object is the main interface for estimating thermodynamic properties, vapor-liquid equilibrium, and material and energy balances. First set the thermo property package and we can start creating streams:

[33]:
tmo.settings.set_thermo(thermo)
s1 = tmo.Stream('s1', Water=20, Ethanol=20, units='kg/hr')
s1.show(flow='kg/hr')
Stream: s1
 phase: 'l', T: 298.15 K, P: 101325 Pa
 flow (kg/hr): Water    20
               Ethanol  20

Create another stream at a higher temperature:

[34]:
s2 = tmo.Stream('s2', Water=10, units='kg/hr', T=350, P=101325)
s2.show(flow='kg/hr')
Stream: s2
 phase: 'l', T: 350 K, P: 101325 Pa
 flow (kg/hr): Water  10

Mix both stream into a new one:

[35]:
s_mix = tmo.Stream('s_mix')
s_mix.mix_from([s1, s2])
s_mix.show(flow='kg/hr')
Stream: s_mix
 phase: 'l', T: 310.53 K, P: 101325 Pa
 flow (kg/hr): Water    30
               Ethanol  20

Check the energy balance through enthalpy:

[36]:
s_mix.H - (s1.H + s2.H)
[36]:
4.0605300455354154e-08

Note that the balance is not perfect as the solver stops within a small temperature tolerance. However, the approximation is less than 0.01% off:

[37]:
error = s_mix.H - (s1.H + s2.H)
percent_error = 100 * error / (s1.H + s2.H)
print(f"{percent_error:.2%}")
0.00%

Split the mixture to two streams by defining the component splits:

[38]:
# First define an array of component splits
component_splits = s_mix.chemicals.array(['Water', 'Ethanol'], [0, 1])
s_mix.split_to(s1, s2, component_splits)
s1.T = s2.T = s_mix.T # Take care of energy balance
s1.show(flow='kg/hr')
s2.show(flow='kg/hr')
Stream: s1
 phase: 'l', T: 310.53 K, P: 101325 Pa
 flow (kg/hr): Ethanol  20
Stream: s2
 phase: 'l', T: 310.53 K, P: 101325 Pa
 flow (kg/hr): Water  30

3.3. Flow rates

The most convinient way to get and set flow rates is through the get_flow and set_flow methods:

[39]:
# Set and get flow of a single chemical
# in gallons per minute
s1.set_flow(1, 'gpm', 'Water')
s1.get_flow('gpm', 'Water')
[39]:
1.0
[40]:
# Set and get flows of many chemicals
# in kilograms per hour
s1.set_flow([10, 20], 'kg/hr', ('Ethanol', 'Water'))
s1.get_flow('kg/hr', ('Ethanol', 'Water'))
[40]:
array([10., 20.])

It is also possible to index flow rate data using chemical IDs through the imol, imass, and ivol indexers:

[41]:
s1.imol.show()
ChemicalMolarFlowIndexer (kmol/hr):
 (l) Water    1.11
     Ethanol  0.2171
[42]:
s1.imol['Water']
[42]:
1.1101687012358397
[43]:
s1.imol['Ethanol', 'Water']
[43]:
array([0.217, 1.11 ])

All flow rates are stored as an array in the mol attribute:

[44]:
s1.mol # Molar flow rates [kmol/hr]
[44]:
array([1.11 , 0.217, 0.   ])

Mass and volumetric flow rates are available as property arrays:

[45]:
s1.mass
[45]:
property_array([<Water: 20 kg/hr>, <Ethanol: 10 kg/hr>,
                <Propanol: 0 kg/hr>])
[46]:
s1.vol
[46]:
property_array([<Water: 0.020166 m^3/hr>, <Ethanol: 0.012898 m^3/hr>,
                <Propanol: 0 m^3/hr>])

These arrays work just like ordinary arrays, but the data is linked to the molar flows:

[47]:
# Mass flows are always up to date with molar flows
s1.mol[0] = 1
s1.mass[0]
[47]:
<Water: 18.015 kg/hr>
[48]:
# Changing mass flows changes molar flows
s1.mass[0] *= 2
s1.mol[0]
[48]:
2.0
[49]:
# Property arrays act just like normal arrays
s1.mass + 2 # A new array is created
[49]:
array([38.031, 12.   ,  2.   ])
[50]:
# Array methods are also the same
s1.mass.mean()
[50]:
15.34352

3.4. Thermal condition

Temperature and pressure can be get and set through the T and P attributes:

[51]:
s1.T = 400.
s1.P = 2 * 101325.
s1.show()
Stream: s1
 phase: 'l', T: 400 K, P: 202650 Pa
 flow (kmol/hr): Water    2
                 Ethanol  0.217

The phase may also be changed (‘s’ for solid, ‘l’ for liquid, and ‘g’ for gas):

[52]:
s1.phase = 'g'

Notice that VLE is not enforced, but it is possible to perform. For now, just check that the dew point is lower than the actual temperature to assert it must be gas:

[53]:
dp = s1.dew_point_at_P() # Dew point at constant pressure
dp
[53]:
DewPointValues(T=390.90753555806145, P=202650.0, IDs=('Water', 'Ethanol'), z=[0.902 0.098], x=[0.991 0.009])
[54]:
dp.T < s1.T
[54]:
True

It is also possible to get and set in other units of measure:

[55]:
s1.set_property('P', 1, 'atm')
s1.get_property('P', 'atm')
[55]:
1.0
[56]:
s1.set_property('T', 125, 'degC')
s1.get_property('T', 'degF')
[56]:
257.0000004

Enthalpy can also be set. An energy balance is made to solve for temperature at isobaric conditions:

[57]:
s1.H = s1.H + 500
s1.get_property('T', 'degC') # Temperature should go up
[57]:
130.80216020713658

3.5. Thermal properties

Thermodynamic properties are pressure, temperature and phase dependent. In the following examples, let’s just use water as it is easier to check properties:

[58]:
s_water = tmo.Stream('s_water', Water=1, units='kg/hr')
s_water.rho # Density [kg/m^3]
[58]:
997.0156689562491
[59]:
s_water.T = 350
s_water.rho # Density changes
[59]:
971.4430230945908

Get properties in different units:

[60]:
s_water.get_property('sigma', 'N/m') # Surface tension
[60]:
0.06329591766859191
[61]:
s_water.get_property('V', 'm3/kmol') # Molar volume
[61]:
0.01854486528979459

3.6. Flow properties

Several flow properties are available, such as net material and energy flow rates:

[62]:
# Net molar flow rate [kmol/hr]
s_water.F_mol
[62]:
0.05550843506179199
[63]:
# Net mass flow rate [kg/hr]
s_water.F_mass
[63]:
1.0
[64]:
# Net volumetric flow rate [m3/hr]
s_water.F_vol
[64]:
0.0010293964506682433
[65]:
# Enthalpy flow rate [kJ/hr]
s_water.H
[65]:
216.85380295250482
[66]:
# Entropy flow rate [kJ/hr]
s_water.S
[66]:
0.670540696937784
[67]:
# Capacity flow rate [J/K]
s_water.C
[67]:
4.197679245159573

3.7. Thermodynamic equilibrium

Before moving into performing vapor-liquid and liquid-liquid equilibrium calculations, it may be useful to have a look at the phase envelopes to understand chemical interactions and ultimately how they separate between phases.

Plot the binary phase evelope of two chemicals in vapor-liquid equilibrium at constant pressure:

[68]:
eq = tmo.equilibrium # Thermosteam's equilibrium module
eq.plot_vle_binary_phase_envelope(['Ethanol', 'Water'], P=101325)
../_images/tutorial_An_hour_blitz_to_practical_thermodynamics_118_0.png

Plot the ternary phase diagram of three chemicals in liquid-liquid equilibrium at constant pressure:

[69]:
# This one will take like 30 seconds
# Thermosteam's LLE algorithm is stochastic,
# so its much slower than the VLE algorithm.
eq.plot_lle_ternary_diagram('Water', 'Ethanol', 'EthylAcetate', T=298.15)
../_images/tutorial_An_hour_blitz_to_practical_thermodynamics_120_0.png

3.8. Vapor-liquid equilibrium

Vapor-liquid equilibrium can be performed by setting 2 degrees of freedom from the following list: T (Temperature; in K), P (Pressure; in Pa), V (Vapor fraction), and H (Enthalpy; in kJ/hr).

For example, set vapor fraction and pressure:

[70]:
s_eq = tmo.Stream('s_eq', Water=10, Ethanol=10)
s_eq.vle(V=0.5, P=101325)
s_eq.show(composition=True)
MultiStream: s_eq
 phases: ('g', 'l'), T: 353.88 K, P: 101325 Pa
 composition: (g) Water    0.3862
                  Ethanol  0.6138
                  -------  10 kmol/hr
              (l) Water    0.6138
                  Ethanol  0.3862
                  -------  10 kmol/hr

Note that the stream is a now a MultiStream to manage multiple phases. Each phase can be accessed separately too:

[71]:
s_eq['l'].show()
Stream:
 phase: 'l', T: 353.88 K, P: 101325 Pa
 flow (kmol/hr): Water    6.14
                 Ethanol  3.86
[72]:
s_eq['g'].show()
Stream:
 phase: 'g', T: 353.88 K, P: 101325 Pa
 flow (kmol/hr): Water    3.86
                 Ethanol  6.14

Note that the phase of these substreams cannot be changed:

[73]:
s_eq['g'].phase = 'l'
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-73-ed0136a78442> in <module>
----> 1 s_eq['g'].phase = 'l'

~\OneDrive\Code\thermosteam\thermosteam\_stream.py in phase(self, phase)
    500     @phase.setter
    501     def phase(self, phase):
--> 502         self._imol.phase = phase
    503
    504     @property

~\OneDrive\Code\thermosteam\thermosteam\indexer.py in phase(self, phase)
    206     @phase.setter
    207     def phase(self, phase):
--> 208         self._phase.phase = phase
    209
    210     def __format__(self, tabs=""):

~\OneDrive\Code\thermosteam\thermosteam\_phase.py in __setattr__(self, name, value)
     52     def __setattr__(self, name, value):
     53         if value != self.phase:
---> 54             raise AttributeError('phase is locked')
     55
     56 NoPhase = LockedPhase(None)

AttributeError: phase is locked

Again, the most convinient way to get and set flow rates in is through the get_flow and set_flow methods:

[74]:
# Set flow of liquid water
s_eq.set_flow(1, 'gpm', ('l', 'Water'))
s_eq.get_flow('gpm', ('l', 'Water'))
[74]:
1.0
[75]:
# Set multiple liquid flows
key = ('l', ('Ethanol', 'Water'))
s_eq.set_flow([10, 20], 'kg/hr', key)
s_eq.get_flow('kg/hr', key)
[75]:
array([10., 20.])

Chemical flows across all phases can be retrieved if no phase is given:

[76]:
# Get water and ethanol flows summed across all phases
s_eq.get_flow('kg/hr', ('Water', 'Ethanol'))
[76]:
array([ 89.567, 292.79 ])

However, setting chemical data of MultiStream objects requires the phase to be specified:

[77]:
s_eq.set_flow([10, 20], 'kg/hr', ('Water', 'Ethanol'))
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
<ipython-input-77-d6cf98178f52> in <module>
----> 1 s_eq.set_flow([10, 20], 'kg/hr', ('Water', 'Ethanol'))

~\OneDrive\Code\thermosteam\thermosteam\_multi_stream.py in set_flow(self, data, units, key)
    278         name, factor = self._get_flow_name_and_factor(units)
    279         indexer = getattr(self, 'i' + name)
--> 280         indexer[key] = np.asarray(data, dtype=float) / factor
    281
    282     ### Stream data ###

~\OneDrive\Code\thermosteam\thermosteam\indexer.py in __setitem__(self, key, data)
    421         index = self.get_index(key)
    422         if isa(index, ChemicalIndex):
--> 423             raise IndexError("multiple phases present; must include phase key "
    424                              "to set chemical data")
    425         self._data[index] = data

IndexError: multiple phases present; must include phase key to set chemical data

Similar to Stream objects, all flow rates can be accessed through the imol, imass, and ivol attributes:

[78]:
s_eq.imol # Molar flow rates
MolarFlowIndexer (kmol/hr):
 (g) Water     3.862
     Ethanol   6.138
 (l) Water     1.11
     Ethanol   0.2171
[79]:
# Index a single chemical in the liquid phase
s_eq.imol['l', 'Water']
[79]:
1.1101687012358397
[80]:
# Index multiple chemicals in the liquid phase
s_eq.imol['l', ('Ethanol', 'Water')]
[80]:
array([0.217, 1.11 ])
[81]:
# Index the vapor phase
s_eq.imol['g']
[81]:
array([3.862, 6.138, 0.   ])
[82]:
# Index flow of chemicals summed across all phases
s_eq.imol['Ethanol', 'Water']
[82]:
array([6.356, 4.972])

Because multiple phases are present, overall chemical flows in MultiStream objects cannot be set like in Stream objects:

[83]:
s_eq.imol['Ethanol', 'Water'] = [1, 0]
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
<ipython-input-83-fcb482ddb0a2> in <module>
----> 1 s_eq.imol['Ethanol', 'Water'] = [1, 0]

~\OneDrive\Code\thermosteam\thermosteam\indexer.py in __setitem__(self, key, data)
    421         index = self.get_index(key)
    422         if isa(index, ChemicalIndex):
--> 423             raise IndexError("multiple phases present; must include phase key "
    424                              "to set chemical data")
    425         self._data[index] = data

IndexError: multiple phases present; must include phase key to set chemical data

Chemical flows must be set by phase:

[84]:
s_eq.imol['l', ('Ethanol', 'Water')] = [1, 0]

One main difference between a MultiStream object and a Stream object is that the mol attribute no longer stores any data, it simply returns the total flow rate of each chemical. Setting an element of the array raises an error to prevent the wrong assumption that the data is linked:

[85]:
s_eq.mol
[85]:
array([3.862, 7.138, 0.   ])
[86]:
s_eq.mol[0] = 1
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-86-632093460ce3> in <module>
----> 1 s_eq.mol[0] = 1

ValueError: assignment destination is read-only

Note that for both Stream and MultiStream objects, get_flow, imol, and mol return chemical flows across all phases when given only chemical IDs.

3.9. Liquid-liquid equilibrium

Liquid-liquid equilibrium (LLE) only requires the temperature. Pressure is not a significant variable as liquid fungacity coefficients are not a strong function of pressure.

[87]:
tmo.settings.set_thermo(['Water', 'Butanol', 'Octane'])
liquid_mixture = tmo.Stream('liquid_mixture', Water=100, Octane=100, Butanol=5)
liquid_mixture.lle(T=300)
liquid_mixture
MultiStream: liquid_mixture
 phases: ('L', 'l'), T: 300 K, P: 101325 Pa
 flow (kmol/hr): (L) Water    1.458
                     Butanol  3.791
                     Octane   100
                 (l) Water    98.54
                     Butanol  1.209
                     Octane   0.001977

Compared to VLE, LLE is several orders of magnitude times slower. This is because differential evolution, a purely stochastic method, is used to find the solution that globally minimizes the gibb’s free energy of both phases. For now, the LLE algorithm may not present completely accurate results and is subject to change in the future.