Component

Click the badge below to try this tutorial interactively in your browser:

Launch Binder

You can also run this tutorial inGoogle Colab. It takes a one-time setup per session: follow theColab instructions.

  • Prepared by:

  • Learning objectives. After this tutorial, you will be able to:

    • Define a Component and inspect its thermodynamic and biological properties

    • Build a Component registry for a system

    • Switch between mass, mole, and volumetric representations

  • Covered topics:

      1. Component

      1. Components

      1. CompiledComponents

Companion video. A walkthrough of this tutorial is available on YouTube, presented by Tori Morgan. Recorded against QSDsan v1.3.0. The concepts still apply, but if the code on screen differs from this notebook, follow the notebook.

Setup

Import QSDsan and confirm the installed version.

[1]:
import qsdsan as qs
print(f'This tutorial was made with qsdsan v{qs.__version__}.')
This tutorial was made with qsdsan v1.5.3.

1. Component

Component is the most basic class of qsdsan. It can represent a pure chemical (e.g., water) or a group of chemicals that share similar properties (e.g., X_PAO, phosphorus-accumulating organisms).

[2]:
# It's always good to read through the documentation
# commented out to avoid too much output
# help(qs.Component)

1.1. Component from scratch

You can make a Component object from scratch by providing relevant information upon initialization (i.e., creation) of the object.

Four attributes, ID, particle_size, degradability, and organic are required (i.e., must be provided). Specification of measured_as will affect the units and values of the i_ attributes.

[3]:
# We usually capitalize the ID of a Component
XPAO = qs.Component('XPAO', formula = 'C5H7O2N', measured_as = 'COD', phase='l',
                     particle_size = 'Particulate', degradability = 'Slowly',
                     organic = True)
[4]:
# The `show` method is very helpful in getting an overview of the component
# The `chemical_info` argument will show the chemical information of the component, such as formula and molecular weight
XPAO.show(chemical_info=True)
Component: XPAO (phase_ref='l') at phase='l'
[Names]  CAS: XPAO
         InChI: None
         InChI_key: None
         common_name: None
         iupac_name: None
         pubchemid: None
         smiles: None
         formula: C5H7O2N
[Groups] Dortmund: <Empty>
         UNIFAC: <Empty>
         PSRK: <Empty>
         NIST: <Empty>
[Data]   MW: 113.11 g/mol
         Tm: None
         Tb: None
         Tt: None
         Tc: None
         Pt: None
         Pc: None
         Vc: None
         Hf: None
         S0: 0 J/K/mol
         LHV: None
         HHV: None
         Hfus: None
         Sfus: None
         omega: None
         dipole: None
         similarity_variable: None
         iscyclic_aliphatic: None
         combustion: None
Component-specific properties:
[Others] measured_as: COD
         description: None
         particle_size: Particulate
         degradability: Slowly
         organic: True
         i_C: 0.37535 g C/g COD
         i_N: 0.087545 g N/g COD
         i_P: 0 g P/g COD
         i_K: 0 g K/g COD
         i_Mg: 0 g Mg/g COD
         i_Ca: 0 g Ca/g COD
         i_mass: 0.70699 g mass/g COD
         i_charge: 0 mol +/g COD
         i_COD: 1 g COD/g COD
         i_NOD: 0.4 g NOD/g COD
         f_BOD5_COD: 0
         f_uBOD_COD: 0
         f_Vmass_Totmass: 0
         chem_MW: 113.11
[5]:
# As a comparison, here is the same component without the chemical information
XPAO.show(False)
Component: XPAO (phase_ref='l') at phase='l'
Component-specific properties:
[Others] measured_as: COD
         description: None
         particle_size: Particulate
         degradability: Slowly
         organic: True
         i_C: 0.37535 g C/g COD
         i_N: 0.087545 g N/g COD
         i_P: 0 g P/g COD
         i_K: 0 g K/g COD
         i_Mg: 0 g Mg/g COD
         i_Ca: 0 g Ca/g COD
         i_mass: 0.70699 g mass/g COD
         i_charge: 0 mol +/g COD
         i_COD: 1 g COD/g COD
         i_NOD: 0.4 g NOD/g COD
         f_BOD5_COD: 0
         f_uBOD_COD: 0
         f_Vmass_Totmass: 0
         chem_MW: 113.11
[6]:
# If you provide some inputs that are not legit, you will likely receive an error
# For example, the particle size cannot be "Dissolved liquid" (should be "Dissolved gas")
bad_CH4 = qs.Component(ID='bad_CH4', search_ID='CH4', particle_size='Dissolved liquid',
                       degradability='Readily', organic=False)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[6], line 3
      1 # If you provide some inputs that are not legit, you will likely receive an error
      2 # For example, the particle size cannot be "Dissolved liquid" (should be "Dissolved gas")
----> 3 bad_CH4 = qs.Component(ID='bad_CH4', search_ID='CH4', particle_size='Dissolved liquid',
      4                        degradability='Readily', organic=False)

File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\_component.py:261, in Component.__new__(cls, ID, cache, search_ID, chemical, formula, phase, measured_as, i_C, i_N, i_P, i_K, i_Mg, i_Ca, i_mass, i_charge, i_COD, i_NOD, f_BOD5_COD, f_uBOD_COD, f_Vmass_Totmass, description, particle_size, degradability, organic, **chemical_properties)
    257     if phase: lock_phase(self, phase)
    259 # Assign through the property setters so invalid values are caught at
    260 # creation (setters validate via `check_return_property`)
--> 261 self.particle_size = particle_size
    262 self.degradability = degradability
    263 self.organic = organic

File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\_component.py:522, in Component.particle_size(self, particle_size)
    520 @particle_size.setter
    521 def particle_size(self, particle_size):
--> 522     self._particle_size = check_return_property('particle_size', particle_size)

File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\_component.py:110, in check_return_property(name, value)
    108     return None
    109 if value not in allowed_values[name]:
--> 110     raise ValueError(f'{name} must be in {allowed_values[name]}.')
    111 return value

ValueError: particle_size must be in ('Dissolved gas', 'Soluble', 'Colloidal', 'Particulate').

1.2 Component from Chemical

You can convert a Chemical object (native to biosteam) to Component by providing the extra information needed.

[7]:
H2O_chem = qs.Chemical('H2O')
H2O_chem.show()
Chemical: H2O (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}
[8]:
type(H2O_chem)
[8]:
thermosteam._chemical.Chemical
[9]:
# You can do so by passing the `chemical` kwarg to the Component constructor, and then you can skip the `search_ID` since the chemical information is already provided
H2O_cmp1 = qs.Component('H2O_cmp1', chemical=H2O_chem, particle_size='Soluble', degradability='Undegradable', organic=False)
[10]:
# You can also use the `from_chemical` method
H2O_cmp2 = qs.Component.from_chemical('H2O_cmp2', chemical=H2O_chem, particle_size='Soluble', degradability='Undegradable', organic=False)
[11]:
# For components that are chemicals, you can pass a `search_ID` to look into the database
SNH4 = qs.Component('SNH4', search_ID='Ammonium', measured_as='N', phase='l',
                    particle_size='Soluble', degradability='Undegradable',
                    organic=False)
SNH4.show(True)
Component: SNH4 (phase_ref='l') at phase='l'
[Names]  CAS: 14798-03-9
         InChI: H3N/h1H3/p+1
         InChI_key: QGZKDVFQNNGYKY-U...
         common_name: ammonium
         iupac_name: ('azanium',)
         pubchemid: 223
         smiles: [NH4+]
         formula: H4N+
[Groups] Dortmund: <Empty>
         UNIFAC: <Empty>
         PSRK: <Empty>
         NIST: <Empty>
[Data]   MW: 18.038 g/mol
         Tm: None
         Tb: None
         Tt: None
         Tc: None
         Pt: None
         Pc: None
         Vc: 0.00011214 m^3/mol
         Hf: None
         S0: 0 J/K/mol
         LHV: None
         HHV: None
         Hfus: 0 J/mol
         Sfus: None
         omega: None
         dipole: None
         similarity_variable: 0.27719
         iscyclic_aliphatic: 0
         combustion: None
Component-specific properties:
[Others] measured_as: N
         description: None
         particle_size: Soluble
         degradability: Undegradable
         organic: False
         i_C: 0 g C/g N
         i_N: 1 g N/g N
         i_P: 0 g P/g N
         i_K: 0 g K/g N
         i_Mg: 0 g Mg/g N
         i_Ca: 0 g Ca/g N
         i_mass: 1.2878 g mass/g N
         i_charge: 0.071394 mol +/g N
         i_COD: 0 g COD/g N
         i_NOD: 4.5691 g NOD/g N
         f_BOD5_COD: 0
         f_uBOD_COD: 0
         f_Vmass_Totmass: 0
         chem_MW: 18.038

Python Aside: positional vs. keyword arguments (click to expand)

When calling a function you can pass values positionally (in definition order) or as keyword arguments (name=value). QSDsan objects often take many arguments with defaults, so we usually pass them by keyword to avoid ordering mistakes.

def foo(param0='Husky', param1='is', param2='a', param3='kind', param4='of', param5='dog'):
    print(' '.join((param0, param1, param2, param3, param4, param5)))

foo()                               # Husky is a kind of dog
foo(param0='Fuji', param5='apple')  # Fuji is a kind of apple
foo('Fuji', 'apple')                # Fuji apple a kind of dog  ('apple' lands in param1!)
foo('Fuji', param5='apple')         # Fuji is a kind of apple   (positional then keyword is OK)
# foo(param0='Fuji', 'apple')       # SyntaxError: positional arg can't follow keyword arg
[12]:
# If you create a `Component` with molecular formula,
# attributes such as `i_C`, `i_N` will be automatically calcualted,
H2O = qs.Component('H2O', formula='H2O', particle_size='Soluble', degradability='Undegradable', organic=False)
H2O.i_C
[12]:
0.0
[13]:
# `qsdsan` will raise an error if you want to change the value
H2O.i_C = 1
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[13], line 2
      1 # `qsdsan` will raise an error if you want to change the value
----> 2 H2O.i_C = 1

File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\_component.py:309, in Component.i_C(self, i)
    307 @i_C.setter
    308 def i_C(self, i):
--> 309     self._i_C = self._atom_frac_setter('C', i)

File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\_component.py:291, in Component._atom_frac_setter(self, atom, frac)
    289 if self.formula:
    290     if frac:
--> 291         raise AttributeError('This component has formula, '
    292                              f'i_{atom} is calculated based on formula, '
    293                              'cannot be set.')
    294     else:
    295         if atom in self.atoms.keys():

AttributeError: This component has formula, i_C is calculated based on formula, cannot be set.
[14]:
# If you change the `measured_as` attribute of a `Component`,
# the units and values of the `i_` attributes, such as `i_N`,
# `i_COD` will also be updated automatically.
SNH4.measured_as = None
SNH4.show(False)
Component: SNH4 (phase_ref='l') at phase='l'
Component-specific properties:
[Others] measured_as: None
         description: None
         particle_size: Soluble
         degradability: Undegradable
         organic: False
         i_C: 0 g C/g
         i_N: 0.77649 g N/g
         i_P: 0 g P/g
         i_K: 0 g K/g
         i_Mg: 0 g Mg/g
         i_Ca: 0 g Ca/g
         i_mass: 1 g mass/g
         i_charge: 0.055437 mol +/g
         i_COD: 0 g COD/g
         i_NOD: 3.5478 g NOD/g
         f_BOD5_COD: 0
         f_uBOD_COD: 0
         f_Vmass_Totmass: 0
         chem_MW: 18.038
[15]:
# Note the difference in the `i_N` value and unit before and after changing the `measured_as` attribute
SNH4.measured_as = 'N'
SNH4.show()
Component: SNH4 (phase_ref='l') at phase='l'
Component-specific properties:
[Others] measured_as: N
         description: None
         particle_size: Soluble
         degradability: Undegradable
         organic: False
         i_C: 0 g C/g N
         i_N: 1 g N/g N
         i_P: 0 g P/g N
         i_K: 0 g K/g N
         i_Mg: 0 g Mg/g N
         i_Ca: 0 g Ca/g N
         i_mass: 1.2878 g mass/g N
         i_charge: 0.071394 mol +/g N
         i_COD: 0 g COD/g N
         i_NOD: 4.5691 g NOD/g N
         f_BOD5_COD: 0
         f_uBOD_COD: 0
         f_Vmass_Totmass: 0
         chem_MW: 18.038
[16]:
# Apparently you cannot have a component measured as some element that it does not have
SNH4.measured_as = 'C'
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[16], line 2
      1 # Apparently you cannot have a component measured as some element that it does not have
----> 2 SNH4.measured_as = 'C'

File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\_component.py:463, in Component.measured_as(self, measured_as)
    461 if hasattr(self, '_measured_as'):
    462     if self._measured_as != measured_as:
--> 463         self._convert_i_attr(measured_as)
    464 self._measured_as = measured_as

File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\_component.py:506, in Component._convert_i_attr(self, new)
    501     raise AttributeError(f"Component {self.ID} must be measured as "
    502                          f"either COD or one of its constituent atoms, "
    503                          f"if not as itself.")
    505 if denom == 0:
--> 506     raise ValueError(f'{self.ID} cannot be measured as {new}')
    508 for field in _num_component_properties:
    509     if field.startswith('i_'):

ValueError: SNH4 cannot be measured as C

Tip: Why do we want to set the phase (or “lock the state”) of a Component?

This is to reduce the computation burden in thermodynamic simulation. If we are sure that a particular Component will predominantly stay in a particular phase, then setting it to that phase will erase its properties related to other phases, reducing the needs for phase equilibrium.

1.3. measured_as, i_mass, and the i_ factors

A Component’s flow can be tracked on different bases: as its own mass (the default), as one of its constituent elements (e.g., nitrogen, 'N'), or as chemical oxygen demand ('COD'). The measured_as attribute records which currency you are using, and the i_ factors convert between that basis and mass / element / oxygen demand.

  • measured_as=None (default) — measured as itself, i.e., in grams of its own mass.

  • measured_as='N' (a constituent element) — quantified by its nitrogen content: 1 g of the component means 1 g of N.

  • measured_as='COD' — quantified by its oxygen demand: 1 g means 1 g COD.

Every i_ attribute reads as “g of X per g of the measure unit”:

Attribute

Meaning

i_mass

g of actual component mass per g of the measure unit

i_N, i_C, i_P, i_K, i_Mg, i_Ca

g of that element per g of the measure unit

i_COD, i_NOD

g of carbonaceous / nitrogenous oxygen demand per g of the measure unit

i_charge

mol of positive charge per g of the measure unit

When the component has a chemical formula, qsdsan computes all of these from the formula and measured_as, so they cannot be set by hand.

[17]:
# Ammonium quantified two ways: as its own mass vs. as nitrogen
NH4_as_mass = qs.Component('NH4_as_mass', search_ID='Ammonium', measured_as=None,
                           particle_size='Soluble', degradability='Undegradable', organic=False)
NH4_as_N = qs.Component('NH4_as_N', search_ID='Ammonium', measured_as='N',
                        particle_size='Soluble', degradability='Undegradable', organic=False)
for cmp in (NH4_as_mass, NH4_as_N):
    print(f'{cmp.ID:12} measured_as={str(cmp.measured_as):4}  '
          f'i_mass={cmp.i_mass:.4f}  i_N={cmp.i_N:.4f}  i_NOD={cmp.i_NOD:.4f}')
# as mass -> i_mass=1.0000, i_N=0.7765 (N is ~78% of NH4+ by mass)
# as N    -> i_N=1.0000,   i_mass=1.2878 (1 g N corresponds to ~1.29 g of ammonium)
NH4_as_mass  measured_as=None  i_mass=1.0000  i_N=0.7765  i_NOD=3.5478
NH4_as_N     measured_as=N     i_mass=1.2878  i_N=1.0000  i_NOD=4.5691

Switching measured_as rescales every i_ factor: measuring ammonium as mass gives i_mass=1 and i_N=0.78, while measuring it as N gives i_N=1 and i_mass=1.29.

How this connects to ``ignore_inaccurate_molar_weight``. When a component is measured as an element or COD, “1 unit” of it is 1 g of N (or COD) — not 1 gram of the molecule — so its molecular weight is no longer well defined on that basis. That is why compiling components that have a measured_as raises an error unless you pass ignore_inaccurate_molar_weight=True (keep mass-based results only) or adjust_MW_to_measured_as=True (rescale the MW for components that have a formula, so molar and volumetric flows stay correct). The default of these two attributes are delibrately set to False, in order to alert users of this discrepancy.

2. Components

Components (note the s) objects are like a list (i.e., collection) of Component, we will want to use them to tell qsdsan what components we want to work with in the system.

2.1. Components from scratch

You can create a Components object from scratch by specifying all the Component objects.

[18]:
# Note that you need to provide all components as one iterable,
# hence the double parentheses
# This is euivalent to
# cmps1_list = (XPAO, SNH4)
# cmps1 = qs.Components(cmps1_list)
cmps1 = qs.Components((XPAO, SNH4))
cmps1
Components([XPAO, SNH4])
[19]:
# All the `Component` objects are stored as attributes of the `Components` object
cmps1.XPAO
Component: XPAO (phase_ref='l') at phase='l'
Component-specific properties:
[Others] measured_as: COD
         description: None
         particle_size: Particulate
         degradability: Slowly
         organic: True
         i_C: 0.37535 g C/g COD
         i_N: 0.087545 g N/g COD
         i_P: 0 g P/g COD
         i_K: 0 g K/g COD
         i_Mg: 0 g Mg/g COD
         i_Ca: 0 g Ca/g COD
         i_mass: 0.70699 g mass/g COD
         i_charge: 0 mol +/g COD
         i_COD: 1 g COD/g COD
         i_NOD: 0.4 g NOD/g COD
         f_BOD5_COD: 0
         f_uBOD_COD: 0
         f_Vmass_Totmass: 0
         chem_MW: 113.11
[20]:
# You can add more `Component` objects by appending (adding one per time)
cmps1.append(H2O)
cmps1.show()
Components([XPAO, SNH4, H2O])
[21]:
# or extending (adding several per time)
Methanol = qs.Component('Methanol', search_ID='methanol', particle_size='Soluble', degradability='Readily',
                       organic=True)
Ethanol = qs.Component('Ethanol', search_ID='ethanol', particle_size='Soluble', degradability='Readily',
                       organic=True)
cmps1.extend((Methanol, Ethanol))
cmps1.show()
Components([
    XPAO,     SNH4,    H2O,
    Methanol, Ethanol,
])
[22]:
# Of course, you can make a copy
cmps1_copy = cmps1.copy()
cmps1_copy.show()
Components([
    XPAO,     SNH4,    H2O,
    Methanol, Ethanol,
])

2.2. Components from Chemicals

Similar to how you make Component from Chemical, you can also make Components from Chemicals using the from_chemicals method of Components.

Python Aside: function vs. method (click to expand)

In Python a function becomes a method when it belongs to a class. from_chemicals is a method because it belongs to the Components class. Methods come in three flavors — instance methods, class methods, and static methods — which you can read about in the Python docs.

[23]:
import qsdsan as qs
chems = qs.Chemicals((qs.Chemical('Water'), qs.Chemical('Ethanol')))
data = {'Water': {'particle_size': 'Soluble',
                   'degradability': 'Undegradable',
                   'organic': False},
         'Ethanol': {'particle_size': 'Soluble',
                     'degradability': 'Readily',
                     'organic': False}}
cmps2 = qs.Components.from_chemicals(chems, **data)
cmps2.show()
Components([Water, Ethanol])

2.3. Loading default Components

You can also load the default Components, which are the ones that commonly used in wastewater modeling.

[24]:
cmps3 = qs.Components.load_default(default_compile=False)
cmps3
Components([
    S_H2,      S_CH4,       S_CH3OH,     S_Ac,
    S_Prop,    S_F,         S_U_Inf,     S_U_E,
    C_B_Subst, C_B_BAP,     C_B_UAP,     C_U_Inf,
    X_B_Subst, X_OHO_PHA,   X_GAO_PHA,   X_PAO_PHA,
    X_GAO_Gly, X_PAO_Gly,   X_OHO,       X_AOO,
    X_NOO,     X_AMO,       X_PAO,       X_MEOLO,
    X_FO,      X_ACO,       X_HMO,       X_PRO,
    X_U_Inf,   X_U_OHO_E,   X_U_PAO_E,   X_Ig_ISS,
    X_MgCO3,   X_CaCO3,     X_MAP,       X_HAP,
    X_HDP,     X_FePO4,     X_AlPO4,     X_AlOH,
    X_FeOH,    X_PAO_PP_Lo, X_PAO_PP_Hi, S_NH4,
    S_NO2,     S_NO3,       S_PO4,       S_K,
    S_Ca,      S_Mg,        S_CO3,       S_N2,
    S_O2,      S_CAT,       S_AN,        H2O,
])

2.4. Loading Components from file

You can also load a set of Components by providing a datasheet of component attributes. Here is an example datasheet containing information of 55 components.

[25]:
import pandas as pd
from qsdsan.utils import data_path
import os
file_path = os.path.join(data_path, '_components.tsv')
df = pd.read_csv(file_path, sep='\t', header=0)
df.head()
[25]:
ID description formula particle_size degradability ... f_BOD5_COD f_uBOD_COD f_Vmass_Totmass CAS PubChem
0 S_H2 Dissolved dihydrogen gas H2 Dissolved gas Readily ... 0 0 1 1333-74-0 783
1 S_CH4 Dissolved Methane CH4 Dissolved gas Readily ... 0 0 1 74-82-8 297
2 S_CH3OH Methanol CH3OH Soluble Readily ... 0.717 0.863 1 67-56-1 887
3 S_Ac Acetate CH3COO(-) Soluble Readily ... 0.717 0.863 1 71-50-1 175
4 S_Prop Propionate C2H5COO- Soluble Readily ... 0.717 0.863 1 72-03-7 1.05e+05

5 rows × 22 columns

[26]:
cmps4 = qs.Components.load_from_file(df)
cmps4.show()
Components([
    S_H2,      S_CH4,       S_CH3OH,     S_Ac,
    S_Prop,    S_F,         S_U_Inf,     S_U_E,
    C_B_Subst, C_B_BAP,     C_B_UAP,     C_U_Inf,
    X_B_Subst, X_OHO_PHA,   X_GAO_PHA,   X_PAO_PHA,
    X_GAO_Gly, X_PAO_Gly,   X_OHO,       X_AOO,
    X_NOO,     X_AMO,       X_PAO,       X_MEOLO,
    X_FO,      X_ACO,       X_HMO,       X_PRO,
    X_U_Inf,   X_U_OHO_E,   X_U_PAO_E,   X_Ig_ISS,
    X_MgCO3,   X_CaCO3,     X_MAP,       X_HAP,
    X_HDP,     X_FePO4,     X_AlPO4,     X_AlOH,
    X_FeOH,    X_PAO_PP_Lo, X_PAO_PP_Hi, S_NH4,
    S_NO2,     S_NO3,       S_PO4,       S_K,
    S_Ca,      S_Mg,        S_CO3,       S_N2,
    S_O2,      S_CAT,       S_AN,
])

3. CompiledComponents

Finally, we need to “compile” Components into CompiledComponents, which can be easily done using the compile method.

When executing the compile method, the package will check if all Component objects have the properties needed for thermodynamic calculation.

[27]:
# For example, we will receive an error if we try to compile cmps1,
# because there is not enough data
cmps1.compile()
---------------------------------------------------------------------------
RuntimeError                              Traceback (most recent call last)
Cell In[27], line 3
      1 # For example, we will receive an error if we try to compile cmps1,
      2 # because there is not enough data
----> 3 cmps1.compile()

File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\_components.py:183, in Components.compile(self, skip_checks, ignore_inaccurate_molar_weight, adjust_MW_to_measured_as)
    159 '''
    160 Cast as a :class:`CompiledComponents` object.
    161
   (...)    180 :func:`Components.default_compile` for a fuller explanation and examples.
    181 '''
    182 components = tuple(self)
--> 183 prepare_chemicals(components, skip_checks)
    184 setattr(self, '__class__', CompiledComponents)
    186 try: self._compile(components, ignore_inaccurate_molar_weight, adjust_MW_to_measured_as)

File c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\thermosteam\_chemicals.py:43, in prepare(chemicals, skip_checks)
     41 if not missing_properties: continue
     42 missing = utils.repr_listed_values(missing_properties)
---> 43 raise RuntimeError(
     44     f"{chemical} is missing key thermodynamic properties ({missing}); "
     45     "use the `<Chemical>.get_missing_properties()` to check "
     46     "all missing properties"
     47 )

RuntimeError: XPAO is missing key thermodynamic properties (V, S, H and Cn); use the `<Chemical>.get_missing_properties()` to check all missing properties
[28]:
# Before compiling, you can see exactly what each component still lacks with
# `get_missing_properties` (here XPAO has no thermodynamic models yet)
XPAO.get_missing_properties()
[28]:
['S_excess',
 'H_excess',
 'mu',
 'kappa',
 'V',
 'S',
 'H',
 'Cn',
 'Psat',
 'Hvap',
 'sigma',
 'epsilon',
 'Dortmund',
 'UNIFAC',
 'PSRK',
 'Hf',
 'LHV',
 'HHV',
 'combustion',
 'Tt',
 'Hfus',
 'Tb',
 'Pc',
 'Pt',
 'Vc',
 'Sfus',
 'Tm',
 'Tc',
 'omega',
 'iscyclic_aliphatic',
 'dipole',
 'similarity_variable']

Note: Some of these components (e.g., XPAO, measured as COD) are defined with a measured_as basis, so their molecular weight is not consistent with that basis and qsdsan cannot compute accurate molar/volumetric flows for them. compile() raises a RuntimeError to flag this. Pass ignore_inaccurate_molar_weight=True to proceed using mass-based quantities only, or adjust_MW_to_measured_as=True to fix the MW for components that have a chemical formula.

[29]:
# We can either provide the missing data as indicated in the error method,
# or we can use the `default` method and copy some data from H2O (or other more relevant components)
for i in cmps1:
    if i is H2O:
        continue # "continue" means skip the rest of the codes and continue with the next one in the loop
    i.default()
    i.copy_models_from(H2O, names=('sigma', 'epsilon', 'kappa', 'V', 'Cn', 'mu'))

# Now we can compile
cmps1.compile(ignore_inaccurate_molar_weight=True)
cmps1.show()
CompiledComponents([
    XPAO,     SNH4,    H2O,
    Methanol, Ethanol,
])

Shortcut: Instead of filling in models component by component, Components.default_compile() auto-fills the missing properties (boiling point and molar volume from a phase-appropriate reference, the rest from water) and compiles in one call. This is what load_default() uses under the hood.

[30]:
# For the default `Components` objects, the easy way is to let it compile during loading
cmps3 = qs.Components.load_default()
cmps3.show()
CompiledComponents([
    S_H2,      S_CH4,       S_CH3OH,     S_Ac,
    S_Prop,    S_F,         S_U_Inf,     S_U_E,
    C_B_Subst, C_B_BAP,     C_B_UAP,     C_U_Inf,
    X_B_Subst, X_OHO_PHA,   X_GAO_PHA,   X_PAO_PHA,
    X_GAO_Gly, X_PAO_Gly,   X_OHO,       X_AOO,
    X_NOO,     X_AMO,       X_PAO,       X_MEOLO,
    X_FO,      X_ACO,       X_HMO,       X_PRO,
    X_U_Inf,   X_U_OHO_E,   X_U_PAO_E,   X_Ig_ISS,
    X_MgCO3,   X_CaCO3,     X_MAP,       X_HAP,
    X_HDP,     X_FePO4,     X_AlPO4,     X_AlOH,
    X_FeOH,    X_PAO_PP_Lo, X_PAO_PP_Hi, S_NH4,
    S_NO2,     S_NO3,       S_PO4,       S_K,
    S_Ca,      S_Mg,        S_CO3,       S_N2,
    S_O2,      S_CAT,       S_AN,        H2O,
])
[31]:
# Once compiled, you can no longer add `Component` to it
# so the following code will raise an error
cmps1.append(cmps3.S_H2)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[31], line 3
      1 # Once compiled, you can no longer add `Component` to it
      2 # so the following code will raise an error
----> 3 cmps1.append(cmps3.S_H2)

File c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\thermosteam\utils\decorators\read_only.py:14, in deny(self, *args, **kwargs)
     13 def deny(self, *args, **kwargs):
---> 14     raise TypeError(f"'{type(self).__name__}' object is read-only")

TypeError: 'CompiledComponents' object is read-only
[32]:
# If you really need to add something, the best way (other than adding it before compiling)
# would be to make a new `Components` containing all the `Component` objects in the `CompiledComponent`
cmps4 = qs.Components(cmps1)
cmps4.append(cmps3.S_H2)
cmps4.show()
Components([
    XPAO,    SNH4, H2O, Methanol,
    Ethanol, S_H2,
])
[33]:
# If you only want to work with a subset of the `CompiledComponents`,
# you can hand pick the `Component` IDs you want and input it to the
# `.subgroup()` method.
cmps5 = cmps3.subgroup(['S_CH4', 'S_Ac', 'S_F', 'S_NH4'])
cmps5.show()
CompiledComponents([S_CH4, S_Ac, S_F, S_NH4])

Tip: Why do we go through all the trouble to do the compilation? This is because compilation allows us to “freezes” the order of the properties (e.g., MW) and work with numbers only. It also allows us to use libraries like numpy and numba to speed things up. To learn more, check out this paper on scientific application of numpy.

[34]:
# Once compiled, you can set synonyms of `Component` so that you can find one component by any of its synonyms
# note that its ID won't change
cmps1.set_synonym('H2O', 'Water')
print(cmps1.H2O.ID)
print(cmps1.Water.ID)
H2O
H2O
[35]:
# In fact, all of the synonyms point to the same object
cmps1.H2O is cmps1.Water
[35]:
True

Python Aside: identity vs. equality (click to expand)

is checks whether two names point to the same object in memory, which is stricter than == (equal contents). Two lists can be equal but not identical:

lst1 = [1, 2]
lst2 = lst1.copy()
lst1 is lst2          # False - different objects
lst1 == lst2          # True  - same contents
id(lst1), id(lst2)    # different memory addresses

That is why cmps1.H2O is cmps1.Water above returns True: the synonym does not create a new object, it points to the existing one.

3.1. Selecting components by property

Compiling does more than freeze the property order — it also builds boolean (1/0) arrays, aligned with CompiledComponents.IDs, that flag each component by phase, biodegradability, and origin. These let you pick out groups of components without writing loops. Common ones are .g (dissolved gas), .s (soluble), .c (colloidal), .x (particulate), .b (biodegradable), .rb (readily biodegradable), .org (organic), and .inorg (inorganic).

[36]:
cmps3 = qs.Components.load_default()
cmps3.g  # 1 for each dissolved gas, 0 otherwise
[36]:
array([1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0])
[37]:
# Translate a 1/0 array into the component IDs it selects
cmps3.get_IDs_from_array(cmps3.g)
[37]:
('S_H2', 'S_CH4', 'S_N2', 'S_O2')
[38]:
# ...and the other way, from a set of IDs to an aligned 1/0 array
cmps3.get_array_from_IDs(('S_H2', 'S_CH4', 'S_N2', 'S_O2'))
[38]:
array([1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
       0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0])

3.2. Defining component groups

A group is a named subset of components. QSDsan uses groups to compute composite variables: the built-in TKN group, for instance, powers WasteStream.TKN and composite(subgroup=cmps.TKN) (see 3. WasteStream). Define your own with define_group, then use the name anywhere a subgroup is accepted.

[39]:
# Group components under a name, then reuse it (here, the volatile fatty acids)
cmps3.define_group('VFAs', IDs=('S_Ac', 'S_Prop'))
[c.ID for c in cmps3.VFAs]
[39]:
['S_Ac', 'S_Prop']

↑ Back to top