15. ThermoSTEAM 101#

15.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 contain the same thermodynamic models and data as thermo’s Chemical objects, but present an API more suitable for BioSTEAM’s needs.

[1]:
import thermosteam as tmo
# Initialize chemical with an identifier (e.g. by name, CAS, InChI...)
Water = tmo.Chemical('Water')
Water.show()
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>
         NIST: <Empty>
[Data]   MW: 18.015 g/mol
         Tm: 273.15 K
         Tb: 373.12 K
         Tt: 273.16 K
         Tc: 647.1 K
         Pt: 611.65 Pa
         Pc: 2.2064e+07 Pa
         Vc: 5.5948e-05 m^3/mol
         Hf: -2.8582e+05 J/mol
         S0: 70 J/K/mol
         LHV: -44011 J/mol
         HHV: -0 J/mol
         Hfus: 6010 J/mol
         Sfus: None
         omega: 0.3443
         dipole: 1.85 Debye
         similarity_variable: 0.16653
         iscyclic_aliphatic: 0
         combustion: {'H2O': 1.0}

Access pure component data:

[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.124295848

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

[5]:
# Vapor pressure (Pa)
Water.Psat(T=373.15)
[5]:
101417.99665995422
[6]:
# Surface tension (N/m)
Water.sigma(T=298.15)
[6]:
0.07197220523022962
[7]:
# Liquid molar volume (m^3/mol)
Water.V(phase='l', T=298.15, P=101325)
[7]:
1.8068319928499427e-05
[8]:
# Vapor molar volume (m^3/mol)
Water.V(phase='g', T=298.15, P=101325)
[8]:
0.02350635143332423
[9]:
# With user-specified units of measure:
Water.get_property('rho', 'kg/m3', phase='l', T=298.15, P=101325)
[9]:
997.0644792261086

Temperature dependent properties are managed by objects:

[10]:
Water.Psat
[10]:
VaporPressure(CASRN="7732-18-5", Tb=373.124295848, Tc=647.096, Pc=22064000.0, omega=0.3443, extrapolation="AntoineAB|DIPPR101_ABC", method="IAPWS")

Phase dependent properties have attributes with model handles for each phase:

[11]:
Water.V
PhaseTPHandle(phase, T, P=None) -> V [m^3/mol]
[12]:
Water.V.l
[12]:
VolumeLiquid(CASRN="7732-18-5", MW=18.01528, Tb=373.124295848, Tc=647.096, Pc=22064000.0, Vc=5.59480372671e-05, Zc=0.22943845208106295, omega=0.3443, dipole=1.85, Psat=VaporPressure(CASRN="7732-18-5", Tb=373.124295848, Tc=647.096, Pc=22064000.0, omega=0.3443, extrapolation="AntoineAB|DIPPR101_ABC", method="IAPWS"), eos=[PR(Tc=647.096, Pc=22064000.0, omega=0.3443, T=298.15, P=101325.0)], extrapolation="constant", method="HEOS_FIT", method_P="COSTALD_COMPRESSED", tabular_extrapolation_permitted=True)

A new model can be added easily using add_method, for example:

[13]:
def User_antoine_model(T):
     return 10.0**(10.116 -  1687.537 / (T - 42.98))
Water.Psat.add_method(f=User_antoine_model, Tmin=273.20, Tmax=473.20)
Water.Psat.method
[13]:
'USER_METHOD'

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

[14]:
Water.Cn.l.add_method(75.31)
Water.Cn('l', 350)
[14]:
75.31

Choose what model to use through the method attribute:

[15]:
Water.Cn.l.all_methods
[15]:
{'COOLPROP',
 'CRCSTD',
 'DADGOSTAR_SHAW',
 'HEOS_FIT',
 'JANAF',
 'POLING_CONST',
 'ROWLINSON_BONDI',
 'ROWLINSON_POLING',
 'USER_METHOD',
 'WEBBOOK_SHOMATE',
 'ZABRANSKY_SPLINE_C'}
[16]:
Water.Cn.l.method = 'ZABRANSKY_SPLINE_C'
Water.Cn('l', 350)
[16]:
75.6223925836403

15.1.1. Managing chemical sets#

Define multiple chemicals as a Chemicals object:

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

The chemicals are attributes:

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

Chemicals are indexable:

[19]:
Water = chemicals['Water']
print(repr(Water))
Chemical('Water')
[20]:
chemicals['Ethanol', 'Water']
[20]:
[Chemical('Ethanol'), Chemical('Water')]

Chemicals are also iterable:

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

More chemicals can also be appended:

[22]:
Propanol = tmo.Chemical('Propanol')
chemicals.append(Propanol)
chemicals
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:

[23]:
# 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.show()
Thermo(
    chemicals=CompiledChemicals([Water, Ethanol, Propanol]),
    mixture=IdealMixture(...
        include_excess_energies=False
    ),
    Gamma=DortmundActivityCoefficients,
    Phi=IdealFugacityCoefficients,
    PCF=MockPoyintingCorrectionFactors
)

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.

15.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:

[24]:
# This also works: tmo.settings.set_thermo(['Water', 'Ethanol', 'Propanol'])
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:

[25]:
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 streams into a new one:

[26]:
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.54 K, P: 101325 Pa
flow (kg/hr): Water    30
              Ethanol  20

Check the energy balance through enthalpy:

[27]:
s_mix.H - (s1.H + s2.H)
[27]:
4.092726157978177e-12

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:

[28]:
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:

[29]:
# 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.54 K, P: 101325 Pa
flow (kg/hr): Ethanol  20
Stream: s2
phase: 'l', T: 310.54 K, P: 101325 Pa
flow (kg/hr): Water  30

Separate out stream from mixture:

[30]:
s_mix.separate_out(s2)
s_mix.show(flow='kg/hr') # Only enthanol will remain
Stream: s_mix
phase: 'l', T: 310.54 K, P: 101325 Pa
flow (kg/hr): Ethanol  20

Note that the energy balance still holds:

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

15.3. Flow rates#

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

[32]:
# Set and get flow of a single chemical
# in gallons per minute
s1.set_flow(1, 'gpm', 'Water')
s1.get_flow('gpm', 'Water')
[32]:
1.0
[33]:
# 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'))
[33]:
array([10., 20.])

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

[34]:
s1.imol.show()
ChemicalMolarFlowIndexer (kmol/hr):
(l) Water    1.11
    Ethanol  0.217
[35]:
s1.imol['Water']
[35]:
1.1101687012358397
[36]:
s1.imol['Ethanol', 'Water']
[36]:
array([0.217, 1.11 ])

All flow rates are stored as a sparse array in the mol attribute. These arrays work just like numpy arrays, but are more scalable (saving memory and increasing speed) for sparse chemical data:

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

Mass and volumetric flow rates are also available for convenience:

[38]:
s1.mass
[38]:
sparse([20., 10.,  0.])
[39]:
s1.vol
[39]:
sparse([0.02 , 0.013, 0.   ])

The data of these arrays are linked to the molar flows:

[40]:
# Mass flows are always up to date with molar flows
s1.mol[0] = 1
s1.mass[0]
[40]:
18.01528
[41]:
# Changing mass flows changes molar flows
s1.mass[0] *= 2
s1.mol[0]
[41]:
2.0
[42]:
# New arrays are not linked to molar flows
s1.mass + 2 # A new array is created
[42]:
sparse([38.031, 12.   ,  2.   ])
[43]:
# Array methods are also the same
s1.mass.mean()
[43]:
15.34352

15.4. Thermal condition#

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

[44]:
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):

[45]:
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:

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

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

[48]:
s1.set_property('P', 1, 'atm')
s1.get_property('P', 'atm')
[48]:
1.0
[49]:
s1.set_property('T', 125, 'degC')
s1.get_property('T', 'degF')
[49]:
256.99999999999994

Enthalpy and entropy can also be set. In both cases, an energy balance is made to solve for temperature at isobaric conditions:

[50]:
s1.H = 1.05 * s1.H
s1.get_property('T', 'degC') # Temperature should go up
[50]:
184.95887197182628
[51]:
s1.S = 0.95 * s1.S
s1.get_property('T', 'degC') # Temperature should go down
[51]:
74.98140649417962

15.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:

[52]:
s_water = tmo.Stream('s_water', Water=1, units='kg/hr')
s_water.rho # Density [kg/m^3]
[52]:
997.0644792261086
[53]:
s_water.T = 350
s_water.rho # Density changes
[53]:
973.7419512246709

Get properties in different units:

[54]:
s_water.get_property('sigma', 'N/m') # Surface tension
[54]:
0.06324769600985489
[55]:
s_water.get_property('V', 'm3/kmol') # Molar volume
[55]:
0.01850108232200766

15.6. Flow properties#

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

[56]:
# Net molar flow rate [kmol/hr]
s_water.F_mol
[56]:
0.05550843506179199
[57]:
# Net mass flow rate [kg/hr]
s_water.F_mass
[57]:
1.0
[58]:
# Net volumetric flow rate [m3/hr]
s_water.F_vol
[58]:
0.00102696612664403
[59]:
# Enthalpy flow rate [kJ/hr]
s_water.H
[59]:
216.91884940751328
[60]:
# Entropy flow rate [kJ/hr]
s_water.S
[60]:
4.556326412564778
[61]:
# Capacity flow rate [J/K]
s_water.C
[61]:
4.194462916586701

15.7. Phase equilibrium#

When streams are created, by default, they do not perform phase equilibrium. To perform phase equilibrium assuming 2 liquid phases and 1 gas phase is possible, pass vlle=True:

[62]:
s = tmo.Stream('s', Water=1, Ethanol=1, T=400, P=101325,
               phase='l', # Guess phase
               vlle=True)
s.show() # Notice how stream is a gas (not a liquid)
Stream: s
phase: 'g', T: 400 K, P: 101325 Pa
flow (kmol/hr): Water    1
                Ethanol  1

Performing VLLE is quite slow and it is not recommended to perform VLLE calculations unless 3 phases can exist. When possible, perform phase equilibrium assuming 2 phases instead. Before moving into 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 partition between phases.

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

[63]:
import matplotlib.pyplot as plt
eq = tmo.equilibrium # Thermosteam's equilibrium module
eq.plot_vle_binary_phase_envelope(['Ethanol', 'Water'], P=101325)
plt.show()
../_images/tutorial_ThermoSTEAM_101_110_0.png

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

[64]:
# You'll need to "pip install python-ternary" to run this line
eq.plot_lle_ternary_diagram('Water', 'Ethanol', 'EthylAcetate', T=298.15)
plt.show()
../_images/tutorial_ThermoSTEAM_101_112_0.png

15.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), H (Enthalpy; in kJ/hr), and S (Entropy; in kJ/K/hr).

For example, set vapor fraction and pressure:

[65]:
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.94 K, P: 101325 Pa
composition (%): (g) Water    38.7
                     Ethanol  61.3
                     -------  10 kmol/hr
                 (l) Water    61.3
                     Ethanol  38.7
                     -------  10 kmol/hr

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

[66]:
s_eq['l'].show()
Stream:
phase: 'l', T: 353.94 K, P: 101325 Pa
flow (kmol/hr): Water    6.13
                Ethanol  3.87
[67]:
s_eq['g'].show()
Stream:
phase: 'g', T: 353.94 K, P: 101325 Pa
flow (kmol/hr): Water    3.87
                Ethanol  6.13

Note that the phase of these substreams cannot be changed:

[68]:
s_eq['g'].phase = 'l'
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[68], line 1
----> 1 s_eq['g'].phase = 'l'

File ~\code\biosteam\thermosteam\thermosteam\_stream.py:973, in Stream.phase(self, phase)
    971 @phase.setter
    972 def phase(self, phase):
--> 973     self._imol._phase.phase = phase

File ~\code\biosteam\thermosteam\thermosteam\_phase.py:189, in LockedPhase.__setattr__(self, name, value)
    187 def __setattr__(self, name, value):
    188     if value != self.phase:
--> 189         raise AttributeError('phase is locked')

AttributeError: phase is locked

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

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

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

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

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

[72]:
s_eq.set_flow([10, 20], 'kg/hr', ('Water', 'Ethanol'))
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[72], line 1
----> 1 s_eq.set_flow([10, 20], 'kg/hr', ('Water', 'Ethanol'))

File ~\code\biosteam\thermosteam\thermosteam\_multi_stream.py:458, in MultiStream.set_flow(self, data, units, key)
    456 name, factor = self._get_flow_name_and_factor(units)
    457 indexer = getattr(self, 'i' + name)
--> 458 indexer[key] = np.asarray(data, dtype=float) / factor

File ~\code\biosteam\thermosteam\thermosteam\indexer.py:1036, in MaterialIndexer.__setitem__(self, key, data)
   1034 index, kind, sum_across_phases = self._get_index_data(key)
   1035 if sum_across_phases:
-> 1036     raise IndexError("multiple phases present; must include phase key "
   1037                      "to set chemical data")
   1038 if kind is None:
   1039     if index is None:

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:

[73]:
s_eq.imol # Molar flow rates
MolarFlowIndexer (kmol/hr):
(g) Water     3.87
    Ethanol   6.13
(l) Water     1.11
    Ethanol   0.217
[74]:
# Index a single chemical in the liquid phase
s_eq.imol['l', 'Water']
[74]:
1.1101687012358397
[75]:
# Index multiple chemicals in the liquid phase
s_eq.imol['l', ('Ethanol', 'Water')]
[75]:
array([0.217, 1.11 ])
[76]:
# Index the vapor phase
s_eq.imol['g']
[76]:
sparse([3.874, 6.126, 0.   ])
[77]:
# Index flow of chemicals summed across all phases
s_eq.imol['Ethanol', 'Water']
[77]:
array([6.343, 4.984])

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

[78]:
s_eq.imol['Ethanol', 'Water'] = [1, 0]
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[78], line 1
----> 1 s_eq.imol['Ethanol', 'Water'] = [1, 0]

File ~\code\biosteam\thermosteam\thermosteam\indexer.py:1036, in MaterialIndexer.__setitem__(self, key, data)
   1034 index, kind, sum_across_phases = self._get_index_data(key)
   1035 if sum_across_phases:
-> 1036     raise IndexError("multiple phases present; must include phase key "
   1037                      "to set chemical data")
   1038 if kind is None:
   1039     if index is None:

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

Chemical flows must be set by phase:

[79]:
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:

[80]:
s_eq.mol
[80]:
sparse([3.874, 7.126, 0.   ])
[81]:
s_eq.mol[0] = 1
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[81], line 1
----> 1 s_eq.mol[0] = 1

File ~\code\biosteam\thermosteam\thermosteam\base\sparse.py:1646, in SparseVector.__setitem__(self, index, value)
   1645 def __setitem__(self, index, value):
-> 1646     if self.read_only: raise ValueError('assignment destination is read-only')
   1647     dct = self.dct
   1648     if index.__class__ is tuple:

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.

15.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.

[82]:
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.show()
MultiStream: liquid_mixture
phases: ('L', 'l'), T: 300 K, P: 101325 Pa
flow (kmol/hr): (L) Water    98.5
                    Butanol  1.21
                    Octane   0.00198
                (l) Water    1.46
                    Butanol  3.79
                    Octane   100