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")

[14]:
# Plot all models
Water.Psat.plot_models_vs_T([Water.Tm, Water.Tb], 'degC', 'atm')

[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

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)

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)

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.