SanUnit
(basic)#
Prepared by:
Covered topics:
1. Understanding SanUnit/sanunits/Unit
2. Using existing SanUnit subclasses
Video demo:
To run tutorials in your browser, go to this Binder page.
You can also watch a video demo on YouTube (subscriptions & likes appreciated!).
[1]:
import qsdsan as qs
print(f'This tutorial was made with qsdsan v{qs.__version__}.')
This tutorial was made with qsdsan v1.2.0.
1. Understanding SanUnit
/sanunits
/Unit
#
The SanUnit
class is used to model the design and operation of a unit operation through default or user-implemented algorithms. Upon simulation, it will generate the influent/effluent mass/energy flows and construction/operation inventories. It is (imo) the most extensible class in qsdsan
and most directly relevant to new technologies.
In this tutorial, we will focus on how to use existing SanUnit
classes, creation of new SanUnit
subclasses will be covered in the next tutorial.
1.1. SanUnit
and sanunits
#
[2]:
# In qsdsan's top-level diretory, you will see two entries related to SanUnit:
qs.SanUnit
[2]:
qsdsan._sanunit.SanUnit
This means that SanUnit
is a class in the _sanunit
module (i.e., there is a file called _sanunit.py
within the folder qsdsan
).
_sanunit.py
is the script that contains the codes used to develop the class SanUnit
.
In qsdsan
, Equipment
/equipments
(used to add equipment to a unit operation, discussed later in the advanced SanUnit
tutorial) and Process
/processes
(used for dynamic simulation, will be discussed in a separate tutorial) also follow the same structure.
[3]:
# If you check
qs.sanunits
[3]:
<module 'qsdsan.sanunits' from '/Users/yalinli_cabbi/opt/anaconda3/envs/demo/lib/python3.8/site-packages/qsdsan/sanunits/__init__.py'>
As shown in the output, sanunits
is actually a folder. This folder contains the unit operations (i.e., subclasses of SanUnit
) that have already been developed using qsdsan
, and you can access them through qs.sanunits.<SanUnitName>
Note:
Usually, <Hint>
are used to let readers know that they are supposed to replace whatever in the <>
with their own inputs. So what I meant above is just that you’ll want to replace the <SanUnitName>
with the actual name of the unit operation, e.g., qs.sanunits.PitLatrine
Tip#
How did I know that _sanunit.py
is a file/script but sanunits
is a folder (other than the reason that I made them XD)?
[4]:
# Because when you do
qs._sanunit
[4]:
<module 'qsdsan._sanunit' from '/Users/yalinli_cabbi/opt/anaconda3/envs/demo/lib/python3.8/site-packages/qsdsan/_sanunit.py'>
It ends with _sanunits.py
, but for qs.sanunits
, it ends with .../sanunits/__init__.py
The __init__.py
is a script used in Python to initialize the importing of other scripts within this folder (and you can also add some other codes in it, and those codes would be executed when you import the folder).
[5]:
# If you put in `qs.sanunits.` in the cell/console and hit the tab key on your keyboard,
# you will see a list of the available unit operations you can use
# (all of them have been added as attributes to the `sanunits` module upon importing)
# Sometimes the list won't show up (e.g., your console is busy and takes a while to load),
# you can directly access the list by the `dir` function
dir(qs.sanunits)
[5]:
['ActivatedSludgeProcess',
'AnMBR',
'AnaerobicBaffledReactor',
'AnaerobicCSTR',
'AnaerobicDigestion',
'BeltThickener',
'BiogasCombustion',
'BiogenicRefineryCarbonizerBase',
'BiogenicRefineryControls',
'BiogenicRefineryGrinder',
'BiogenicRefineryHHX',
'BiogenicRefineryHHXdryer',
'BiogenicRefineryHousing',
'BiogenicRefineryIonExchange',
'BiogenicRefineryOHX',
'BiogenicRefineryPollutionControl',
'BiogenicRefineryScrewPress',
'BiogenicRefineryStruvitePrecipitation',
'CH4E',
'CHP',
'CSTR',
'ComponentSplitter',
'Copier',
'CropApplication',
'Decay',
'DryingBed',
'DynamicInfluent',
'EcoSanAerobic',
'EcoSanAnaerobic',
'EcoSanAnoxic',
'EcoSanBioCost',
'EcoSanECR',
'EcoSanMBR',
'EcoSanPrimary',
'EcoSanSolar',
'EcoSanSystem',
'ElectrochemicalCell',
'Excretion',
'FakeSplitter',
'FlatBottomCircularClarifier',
'H2E',
'HXprocess',
'HXutility',
'HydraulicDelay',
'IdealClarifier',
'InternalCirculationRx',
'Lagoon',
'LiquidTreatmentBed',
'LumpedCost',
'MURT',
'MixTank',
'Mixer',
'PitLatrine',
'PolishingFilter',
'Pump',
'ReclaimerECR',
'ReclaimerHousing',
'ReclaimerIonExchange',
'ReclaimerSolar',
'ReclaimerSystem',
'ReclaimerUltrafiltration',
'ReversedSplitter',
'SBR',
'Sampler',
'Screening',
'Sedimentation',
'SepticTank',
'SludgeCentrifuge',
'SludgeDigester',
'SludgePasteurization',
'SludgeSeparator',
'SludgeThickening',
'Splitter',
'StorageTank',
'Tank',
'Toilet',
'Trucking',
'UDDT',
'WWTpump',
'__all__',
'__builtins__',
'__cached__',
'__doc__',
'__file__',
'__loader__',
'__name__',
'__package__',
'__path__',
'__spec__',
'_abstract',
'_activated_sludge_process',
'_anaerobic_reactors',
'_biogenic_refinery',
'_clarifier',
'_combustion',
'_crop_application',
'_decay',
'_dynamic_influent',
'_eco_san',
'_electrochemical_cell',
'_encapsulation_bioreactor',
'_excretion',
'_heat_exchanging',
'_internal_circulation_rx',
'_lagoon',
'_membrane_bioreactors',
'_non_reactive',
'_polishing_filter',
'_pumping',
'_reclaimer',
'_screening',
'_sedimentation',
'_septic_tank',
'_sludge_pasteurization',
'_sludge_thickening',
'_suspended_growth_bioreactor',
'_tanks',
'_toilets',
'_treatment_beds',
'_trucking',
'wwtpump']
Note that there’s more to how dir
works, to know more, check the documentation.
Tip#
Now you might be wondering about the other entries are. Attributes with leading and trailing double underscores (dunder
) are Python builtin attributes, for example
[6]:
# Recall that in previous tutorials we used the `__path__` attribute to look at
# where the imported qsdsan was
qs.sanunits.__path__
[6]:
['/Users/yalinli_cabbi/opt/anaconda3/envs/demo/lib/python3.8/site-packages/qsdsan/sanunits']
Attributes that start with one underscore are private attributes and by default “hidden”, (i.e., if you hit the tab key, they won’t be included in the list).
These are the attributes that developers don’t think user need to/want users to know, that’s why the script that contains the codes used to develop the SanUnit
class is named _sanunit.py
with a leading underscore - the users don’t need to know about the existence of this module, they can just use the SanUnit
class.
1.2. SanUnit
and Unit
#
[7]:
# As always, we learn how to use a class by looking at its documentation
?qs.SanUnit
You can see that SanUnit
is a subclass with the Unit
class in biosteam
, like we mentioned in the previous tutorials, this means it inherits the attributes of the Unit
class while having some new features (e.g., enables dynamic simulation, supports comprehensive LCA).
In qsdsan
, if a class is inherited from a biosteam
(e.g., SanUnit
from Unit
)/thermosteam
(e.g., WasteStream
from Stream
) class, the class-level document will only display the new features, but the documentation of the attributes from the superclasses is still accessible.
[8]:
# E.g., `simulation` is a method inherited from the `Unit` class and you can still see the documentation
# you can actually see the hint on whether the attribute is inherited in the documentation
?qs.SanUnit.simulate
[9]:
# In the case you can't remember what the superclass is, you can do
qs.SanUnit.__bases__
[9]:
(biosteam._unit.Unit,)
Note that the __bases__
attribute is a tuple, this is because one class can have multiple superclasses, in the case of SanUnit
, there is only one
[10]:
# But in the case of the `Toilet` class, there are two
qs.sanunits.Toilet.__bases__
[10]:
(qsdsan._sanunit.SanUnit, qsdsan.sanunits._decay.Decay)
Back to top
2. Using existing SanUnit
subclasses#
In Python terms, the “use” here means creating instances of a SanUnit
subclass.
2.1. Instance initialization (creation) and simulation#
[11]:
# Like always, we need to firstly tell `qsdsan` what components we will be working with,
# for demo purpose we will just use the default ones
cmps = qs.Components.load_default()
qs.set_thermo(cmps)
cmps
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])
Let’s firstly try using a Mixer
, it is one of the most basic units, it takes N numbers of influents and the effluent is the mixture of all influents.
[12]:
# Make three random influents, I'm deliberately using different ways to make these streams
# as a recap previous tutorials
# Method 1: by directly providing the flow rates of select components
ins1 = qs.WasteStream(H2O=100)
# Method 2: using `copy` and adjust flow rates later
ins2 = ins1.copy()
ins2.imol['X_GAO_Gly'] = ins2.imol['X_GAO_PHA'] = 0.01
# Method 3: using default models
ins3 = qs.WasteStream.codstates_inf_model('', flow_tot=50)
[13]:
# Use a shorthand to make our lives easier
su = qs.sanunits
# This is the actual line used to initialize the instance,
# and we can pass the influents through the `ins` argument
M1 = su.Mixer(ins=(ins1, ins2, ins3))
[14]:
# Like many other classes, there is a `show` method
M1.show()
Mixer: M1
ins...
[0] ws1
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): H2O 1e+05
WasteStream-specific properties:
pH : 7.0
[1] ws2
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): X_GAO_PHA 10
X_GAO_Gly 10
H2O 1e+05
WasteStream-specific properties:
pH : 7.0
COD : 199.4 mg/L
BOD : 115.6 mg/L
TC : 70.6 mg/L
TOC : 70.6 mg/L
[2] ws3
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): S_F 4.3
S_U_Inf 1.08
C_B_Subst 2
X_B_Subst 11.3
X_U_Inf 2.79
X_Ig_ISS 2.62
S_NH4 1.25
S_PO4 0.4
S_K 1.4
S_Ca 7
S_Mg 2.5
S_CO3 6
S_N2 0.9
S_CAT 0.15
S_AN 0.6
...
WasteStream-specific properties:
pH : 7.0
COD : 430.0 mg/L
BOD : 221.8 mg/L
TC : 265.0 mg/L
TOC : 137.6 mg/L
TN : 40.0 mg/L
TP : 10.0 mg/L
TK : 28.0 mg/L
outs...
[0] ws4
phase: 'l', T: 298.15 K, P: 101325 Pa
flow: 0
WasteStream-specific properties: None for empty waste streams
Hold on a second, why the effluent is empty?!!! 😱😱😱
[15]:
# Well, we have to simulate the unit first
M1.simulate()
M1.show()
Mixer: M1
ins...
[0] ws1
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): H2O 1e+05
WasteStream-specific properties:
pH : 7.0
[1] ws2
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): X_GAO_PHA 10
X_GAO_Gly 10
H2O 1e+05
WasteStream-specific properties:
pH : 7.0
COD : 199.4 mg/L
BOD : 115.6 mg/L
TC : 70.6 mg/L
TOC : 70.6 mg/L
[2] ws3
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): S_F 4.3
S_U_Inf 1.08
C_B_Subst 2
X_B_Subst 11.3
X_U_Inf 2.79
X_Ig_ISS 2.62
S_NH4 1.25
S_PO4 0.4
S_K 1.4
S_Ca 7
S_Mg 2.5
S_CO3 6
S_N2 0.9
S_CAT 0.15
S_AN 0.6
...
WasteStream-specific properties:
pH : 7.0
COD : 430.0 mg/L
BOD : 221.8 mg/L
TC : 265.0 mg/L
TOC : 137.6 mg/L
TN : 40.0 mg/L
TP : 10.0 mg/L
TK : 28.0 mg/L
outs...
[0] ws4
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): S_F 4.3
S_U_Inf 1.08
C_B_Subst 2
X_B_Subst 11.3
X_GAO_PHA 10
X_GAO_Gly 10
X_U_Inf 2.79
X_Ig_ISS 2.62
S_NH4 1.25
S_PO4 0.4
S_K 1.4
S_Ca 7
S_Mg 2.5
S_CO3 6
S_N2 0.9
...
WasteStream-specific properties:
pH : 7.0
COD : 165.6 mg/L
BOD : 90.5 mg/L
TC : 81.1 mg/L
TOC : 55.7 mg/L
TN : 8.0 mg/L
TP : 2.0 mg/L
TK : 5.6 mg/L
Aha! Now we are seeing the effluent being simulated.
[16]:
# If you recall the `mix_from` method of `WasteStream`,
# effluent of the `Mixer` is actually generated by it
ws4 = qs.WasteStream()
ws4.mix_from(M1.ins)
[17]:
# We can check if the flow rate of ws4 is the same as the effluent of the mixer M1
ws4.mass == M1.outs[0].mass
[17]:
property_array([ True, True, True, True, True, True, True, True,
True, True, True, True, True, True, True, True,
True, True, True, True, True, True, True, True,
True, True, True, True, True, True, True, True,
True, True, True, True, True, True, True, True,
True, True, True, True, True, True, True, True,
True, True, True, True, True, True, True, True]))
[18]:
# As you can see in the above examples,
# I use `ins` and `outs` to set/retrieve the influents and effluents of a unit
# Alternatively, there is the `diagram` method for a more intuitive look
M1.diagram()

HUGE Tip#
If you cannot get a diagram from the diagram
method, you probably don’t have the graphviz
package, which is used to generate the diagram, for more information, check QSDsan’s documentation on this issue
[19]:
# Do the following to confirm that it is indeed `graphviz` causing the problem
import biosteam as bst
bst.RAISE_GRAPHVIZ_EXCEPTION = True
M1.diagram()
# If you can see error message now, then it's definitely `graphvize`, check the link above to fix it

2.2. Retrieving design and cost#
[20]:
# Note that `Mixer` does nothing other than mix the influents,
# let's using another example
qs.set_thermo(cmps) # here we need to reset the `cmps` since I introduced the `biosteam` environment
M2 = su.MixTank('M2', ins=(ins1, ins2, ins3), outs='M2out', # init_with='WasteStream',
tau=1, kW_per_m3=1)
M2.show()
/var/folders/m0/nkxljwvx1nlg1hw9npshjgm40000gn/T/ipykernel_32589/3311111030.py:4: RuntimeWarning: undocked inlet stream ws1 from unit M1; ws1 is now docked at M2
M2 = su.MixTank('M2', ins=(ins1, ins2, ins3), outs='M2out', # init_with='WasteStream',
/var/folders/m0/nkxljwvx1nlg1hw9npshjgm40000gn/T/ipykernel_32589/3311111030.py:4: RuntimeWarning: undocked inlet stream ws2 from unit M1; ws2 is now docked at M2
M2 = su.MixTank('M2', ins=(ins1, ins2, ins3), outs='M2out', # init_with='WasteStream',
/var/folders/m0/nkxljwvx1nlg1hw9npshjgm40000gn/T/ipykernel_32589/3311111030.py:4: RuntimeWarning: undocked inlet stream ws3 from unit M1; ws3 is now docked at M2
M2 = su.MixTank('M2', ins=(ins1, ins2, ins3), outs='M2out', # init_with='WasteStream',
MixTank: M2
ins...
[0] ws1
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): H2O 1e+05
WasteStream-specific properties:
pH : 7.0
[1] ws2
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): X_GAO_PHA 10
X_GAO_Gly 10
H2O 1e+05
WasteStream-specific properties:
pH : 7.0
COD : 199.4 mg/L
BOD : 115.6 mg/L
TC : 70.6 mg/L
TOC : 70.6 mg/L
[2] ws3
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): S_F 4.3
S_U_Inf 1.08
C_B_Subst 2
X_B_Subst 11.3
X_U_Inf 2.79
X_Ig_ISS 2.62
S_NH4 1.25
S_PO4 0.4
S_K 1.4
S_Ca 7
S_Mg 2.5
S_CO3 6
S_N2 0.9
S_CAT 0.15
S_AN 0.6
...
WasteStream-specific properties:
pH : 7.0
COD : 430.0 mg/L
BOD : 221.8 mg/L
TC : 265.0 mg/L
TOC : 137.6 mg/L
TN : 40.0 mg/L
TP : 10.0 mg/L
TK : 28.0 mg/L
outs...
[0] M2out
phase: 'l', T: 298.15 K, P: 101325 Pa
flow: 0
WasteStream-specific properties: None for empty waste streams
Two things to take note of in the example above: - By setting outs='M2out'
, I set the ID of the effluent to be M2out
- You can also make a stream ahead of time and set the effluent to that stream (e.g., outs=qs.WasteStream('M2out')
- If the unit has multiple effluents, then you’ll want to use an Iterable (e.g., tuple, list), e.g., outs=['M2out1', qs.WasteStream('M2out2')]
- This is applicable to ins
as well - You will see warnings about streams being undocked from the
previous unit and docked at the new unit, this is because we set the ins
of M2
to be the same as the ins
of M1
. Since one stream can only go to one unit, these streams will be taken away from M1
and connect to M2
[21]:
# Because `M2` is a `MixTank`, we can look at its design
M2.simulate() # don't forget this!
print(M2.results()) # you can see the design and capital/power cost
Mix tank Units M2
Power Rate kW 0.313
Cost USD/hr 0.0245
Design Residence time hr 1
Total volume m^3 0.313
Number of tanks 1
Purchase cost Tanks USD 7.09e+03
Total purchase cost USD 7.09e+03
Utility cost USD/hr 0.0245
Additional OPEX USD/hr 0
[22]:
# If there is utility usage, it will be shown in the results as well
ws5 = qs.WasteStream(H2O=10, T=20)
H1 = su.HXutility(ins=ws5, T=50)
H1.simulate()
print(H1.results())
HXutility Units U1
Low pressure steam Duty kJ/hr 1.48e+03
Flow kmol/hr 0.0382
Cost USD/hr 0.00909
Design Area ft^2 0.0235
Overall heat transfer coefficient kW/m^2/K 0.5
Log-mean temperature difference K 377
Fouling correction factor 1
Tube side pressure drop psi 1.5
Shell side pressure drop psi 5
Operating pressure psi 50
Total tube length ft 20
Purchase cost Double pipe USD 30.4
Total purchase cost USD 30.4
Utility cost USD/hr 0.00909
Additional OPEX USD/hr 0
[23]:
# You can also retrieve information such as
M2.purchase_cost # this is the sum
[23]:
7094.051785713411
[24]:
M2.purchase_costs # this is a `dict` contains all the entries
[24]:
{'Tanks': 7094.051785713411}
[25]:
# Well, I probably should choose one with more cost items, but you get what I meant
print(f'{M2.ID} contains the following cost items:')
for item_name, item_cost in M2.installed_costs.items():
print(f' {item_name} costs {item_cost:.0f}')
print(f'The sum of all these cost items is {M2.installed_cost:.0f}.')
M2 contains the following cost items:
Tanks costs 11705
The sum of all these cost items is 11705.
Tip#
Some units may require specific components or certain number of influents, for example
[26]:
# You'll receive an `UndefinedComponent` error
# bad_su = su.Excretion()
# bad_su.simulate()
For those units, the best way is to look at the documentation/examples (e.g., for the above case, check the bwaise
system in the link of the documentation).
Back to top