SanUnit (basic)¶
Click the badge below to try this tutorial interactively in your browser:
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:
Instantiate existing
SanUnitsubclasses fromqsdsan.unit_operationsConnect units via influent and effluent streams
Inspect a unit’s design and cost outputs
Prerequisites: 2. Component, 3. WasteStream
Covered topics:
Understanding SanUnit/unit_operations/Unit
Using existing SanUnit subclasses
Companion video. A walkthrough of this tutorial is available on YouTube, presented by Tori Morgan. Recorded against
QSDsanv1.2.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. Understanding SanUnit/unit_operations/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 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 unit_operations¶
[2]:
# In qsdsan's top-level diretory, you will see two entries related to SanUnit:
qs.SanUnit
[2]:
qsdsan._sanunit.SanUnit
SanUnit is the class itself. The unit operations already built with qsdsan (subclasses of SanUnit) live in the unit_operations package, organized into three sub-namespaces (bst, static, dynamic). You access them as qs.<SanUnitName> or qs.unit_operations.<SanUnitName>, e.g. qs.PitLatrine or qs.unit_operations.PitLatrine. Similarly, qsdsan.equipments contains the pre-built Equipment classes, and qsdsan.process_models contains Process
classes.
Note: qsdsan.sanunits is a legacy alias for qsdsan.unit_operations (just as qsdsan.processes aliases qsdsan.process_models). Both names point to the same package, but unit_operations is the current, preferred name.
Python Aside: modules, packages, and naming conventions (click to expand)
A few Python conventions explain the names you see:
Module vs. package.
qs.SanUnitlives in a module (a single file,_sanunit.py);qs.unit_operationsis a package (a directory with an__init__.pythat wires up its contents). A directory is the same thing as a folder — just the more common programmatic name. The file path tells you which is which:
qs._sanunit # .../qsdsan/_sanunit.py (a file)
qs.unit_operations # .../qsdsan/unit_operations/__init__.py (a directory)
Dunder attributes. Names with leading/trailing double underscores (
__path__,__name__, …) are Python built-ins; e.g.qs.unit_operations.__path__gives the directory location.dir()lists these alongside the public names.Leading underscore = private. A single leading underscore (as in
_sanunit.py) marks something developers consider internal — you don’t use it directly, you just useSanUnit.
[3]:
# Hit Tab after `qs.unit_operations.` to autocomplete the available units,
# or list them with `dir`
# dir(qs.unit_operations)
1.2. SanUnit and Unit¶
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).
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¶
[4]:
# 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.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,
])
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.
[5]:
# 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('ins1', H2O=100)
# Method 2: using `copy` and adjust flow rates later
ins2 = ins1.copy('ins2')
ins2.imol['X_GAO_Gly'] = ins2.imol['X_GAO_PHA'] = 0.01
# Method 3: using default models
ins3 = qs.WasteStream.codstates_inf_model('ins3', flow_tot=50)
[6]:
# Use a shorthand to make our lives easier
su = qs.unit_operations
# 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))
[7]:
# Like many other classes, there is a `show` method
M1.show()
Mixer: M1
ins...
[0] ins1
phase: 'l', T: 298.15 K, P: 101325 Pa
flow: 1e+05 g/hr H2O
WasteStream-specific properties:
pH : 7.0
Alkalinity : 2.5 mmol/L
[1] ins2
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
Alkalinity : 2.5 mmol/L
COD : 199.4 mg/L
BOD : 115.6 mg/L
TC : 70.6 mg/L
TOC : 70.6 mg/L
TSS : 143.7 mg/L
[2] ins3
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): S_F 4.3
S_U_Inf 1.07
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
... 4.98e+04
WasteStream-specific properties:
pH : 7.0
Alkalinity : 10.0 mmol/L
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
TSS : 209.3 mg/L
outs...
[0] ws1
phase: 'l', T: 298.15 K, P: 101325 Pa
flow: 0
WasteStream-specific properties: None for empty waste streams
Hold on — why is the effluent empty?
[8]:
# Well, we have to simulate the unit first
M1.simulate()
M1.show()
Mixer: M1
ins...
[0] ins1
phase: 'l', T: 298.15 K, P: 101325 Pa
flow: 1e+05 g/hr H2O
WasteStream-specific properties:
pH : 7.0
Alkalinity : 2.5 mmol/L
[1] ins2
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
Alkalinity : 2.5 mmol/L
COD : 199.4 mg/L
BOD : 115.6 mg/L
TC : 70.6 mg/L
TOC : 70.6 mg/L
TSS : 143.7 mg/L
[2] ins3
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): S_F 4.3
S_U_Inf 1.07
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
... 4.98e+04
WasteStream-specific properties:
pH : 7.0
Alkalinity : 10.0 mmol/L
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
TSS : 209.3 mg/L
outs...
[0] ws1
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): S_F 4.3
S_U_Inf 1.07
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
... 2.5e+05
WasteStream-specific properties:
pH : 7.0
Alkalinity : 4.0 mmol/L
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
TSS : 99.3 mg/L
Aha! Now we are seeing the effluent being simulated.
[9]:
# If you recall the `mix_from` method of `WasteStream`,
# effluent of the `Mixer` is actually generated by it
ws4 = qs.WasteStream('ws4')
ws4.mix_from(M1.ins)
[10]:
# 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
[10]:
sparse([ 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])
[11]:
# 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()
Tip: init_with sets which stream class each port uses: 'WasteStream' (default, or 'ws'), 'SanStream' ('ss'), or 'Stream' ('s'). Pass a single class name to apply it to every port, or a dict to set ports individually, which is handy when, say, the influents are WasteStream but a gas stream should be a plain Stream. Dict keys are ins/outs plus the port number (e.g., ins0, outs-1), a range (e.g., ins2:4), or else for any ports you
did not name. Only WasteStream ports carry the wastewater properties from 3. WasteStream, while Stream ports are plain process streams.
[12]:
# A single class name applies to every port
M_ss = su.Mixer('M_ss', ins=('a', 'b'), outs='c', init_with='SanStream')
print('ins0:', type(M_ss.ins[0]).__name__, '| outs0:', type(M_ss.outs[0]).__name__)
ins0: SanStream | outs0: SanStream
[13]:
# A dict sets ports individually; 'else' covers the rest
M_mix = su.Mixer('M_mix', ins=('a', 'b'), outs='c',
init_with={'ins0': 'Stream', 'else': 'WasteStream'})
print('ins0:', type(M_mix.ins[0]).__name__,
'| ins1:', type(M_mix.ins[1]).__name__,
'| outs0:', type(M_mix.outs[0]).__name__)
ins0: Stream | ins1: WasteStream | outs0: WasteStream
c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\thermosteam\_stream.py:407: RuntimeWarning: <SanStream: a> has been replaced in registry
self._register(ID)
c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\thermosteam\_stream.py:407: RuntimeWarning: <SanStream: b> has been replaced in registry
self._register(ID)
c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\thermosteam\_stream.py:407: RuntimeWarning: <SanStream: c> has been replaced in registry
self._register(ID)
Tip: By default, diagram() renders an inline picture of the flowsheet. You can pass a different format (notably format='html' for an interactive diagram with hover-able stream and unit information) or save it to a file with the file kwarg (e.g., M1.diagram(file='M1', format='png')). If diagram() produces nothing, you likely don’t have the graphviz package installed (it renders the diagrams); see QSDsan’s graphviz
FAQ.
2.2. Retrieving design and cost¶
[14]:
# 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()
C:\Users\Yalin\AppData\Local\Temp\ipykernel_68680\3311111030.py:4: RuntimeWarning: undocked inlet ins1 from M1; ins1 is now docked at M2
M2 = su.MixTank('M2', ins=(ins1, ins2, ins3), outs='M2out', # init_with='WasteStream',
C:\Users\Yalin\AppData\Local\Temp\ipykernel_68680\3311111030.py:4: RuntimeWarning: undocked inlet ins2 from M1; ins2 is now docked at M2
M2 = su.MixTank('M2', ins=(ins1, ins2, ins3), outs='M2out', # init_with='WasteStream',
C:\Users\Yalin\AppData\Local\Temp\ipykernel_68680\3311111030.py:4: RuntimeWarning: undocked inlet ins3 from M1; ins3 is now docked at M2
M2 = su.MixTank('M2', ins=(ins1, ins2, ins3), outs='M2out', # init_with='WasteStream',
MixTank: M2
ins...
[0] ins1
phase: 'l', T: 298.15 K, P: 101325 Pa
flow: 1e+05 g/hr H2O
WasteStream-specific properties:
pH : 7.0
Alkalinity : 2.5 mmol/L
[1] ins2
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
Alkalinity : 2.5 mmol/L
COD : 199.4 mg/L
BOD : 115.6 mg/L
TC : 70.6 mg/L
TOC : 70.6 mg/L
TSS : 143.7 mg/L
[2] ins3
phase: 'l', T: 298.15 K, P: 101325 Pa
flow (g/hr): S_F 4.3
S_U_Inf 1.07
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
... 4.98e+04
WasteStream-specific properties:
pH : 7.0
Alkalinity : 10.0 mmol/L
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
TSS : 209.3 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 beM2outYou 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
insas 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
insofM2to be the same as theinsofM1. Since one stream can only go to one unit, these streams will be taken away fromM1and connect toM2
[15]:
# Because `M2` is a `MixTank`, we can look at its design
M2.simulate() # runs _run then _summary (_design + _cost); see tutorial 5 §1.1
print(M2.results()) # you can see the design and capital/power cost
Mix tank Units M2
Electricity Power kW 0.313
Cost USD/hr 0.0245
Design Residence time hr 1
Total volume m^3 0.313
Purchase cost Tank USD 7.09e+03
Total purchase cost USD 7.09e+03
Utility cost USD/hr 0.0245
[16]:
# If there is utility usage, it will be shown in the results as well
ws5 = qs.WasteStream('ws5', H2O=10, T=20)
H1 = su.HXutility(ins=ws5, T=50)
H1.simulate()
print(H1.results())
Heat exchanger Units H1
Low pressure steam Duty kJ/hr 3.18e+03
Flow kmol/hr 0.0822
Cost USD/hr 0.0196
Design Area ft^2 0.0505
Overall heat transfer coefficient kW/m^2/K 0.5
Log-mean temperature difference K 377
Fouling correction factor 1
Operating pressure psi 50
Total tube length ft 0.116
Inner pipe weight kg 0.12
Outer pipe weight kg 0.193
Total steel weight kg 0.313
Purchase cost Double pipe USD 65.3
Total purchase cost USD 65.3
Utility cost USD/hr 0.0196
[17]:
# You can also retrieve information such as
M2.purchase_cost # this is the sum
[17]:
7093.933003892887
[18]:
M2.purchase_costs # this is a `dict` contains all the entries
[18]:
{'Tank': 7093.933003892887}
[19]:
# (this unit only has one cost item, but the pattern generalizes)
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:
Tank costs 11705
The sum of all these cost items is 11705.
Baseline, purchase, and installed cost. Following the bare-module method (Seider et al.), BioSTEAM derives both costs from a baseline purchase cost (baseline_purchase_costs) using four per-item factors, each defaulting to 1:
F_D(design),F_P(pressure), andF_M(material) give the purchase cost:purchase = baseline × F_D × F_P × F_M.F_BM(bare-module) rolls in installation, piping, instrumentation, etc. to give the installed cost:installed = baseline × (F_BM + F_D × F_P × F_M - 1).
Each factor lives in its own dict (F_BM, F_D, F_P, F_M). For the MixTank above, only F_M (2.0) and F_BM (2.3) differ from 1, so its installed cost is baseline × (2.3 + 2.0 - 1), not purchase × F_BM.
[20]:
M2.F_BM # bare-module factor for each cost item
[20]:
{'Tank': 2.3}
Operating cost beyond utilities. The Additional OPEX row in results() is unit.add_OPEX — costs such as chemicals or labor, given as a float or a dict of USD/hr. The fraction of time a unit actually runs is unit.uptime_ratio (0–1), which scales these operating costs in TEA.
Tip: Some units may require specific components or certain number of influents, for example
[21]:
# You'll receive an `UndefinedComponent` error
bad_su = su.Excretion()
bad_su.simulate()
---------------------------------------------------------------------------
KeyError Traceback (most recent call last)
File c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\thermosteam\_chemicals.py:1283, in CompiledChemicals._get_index_and_kind(self, key)
1282 if key.__hash__ is None: key = tuple(key)
-> 1283 return index_cache[key]
1284 except KeyError:
KeyError: 'NH3'
During handling of the above exception, another exception occurred:
KeyError Traceback (most recent call last)
File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\_components.py:751, in CompiledComponents.index(self, ID)
750 '''Return index of specified component.'''
--> 751 try: return self._index[ID]
752 except KeyError:
KeyError: 'NH3'
During handling of the above exception, another exception occurred:
UndefinedComponent Traceback (most recent call last)
Cell In[21], line 3
1 # You'll receive an `UndefinedComponent` error
2 bad_su = su.Excretion()
----> 3 bad_su.simulate()
File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\_sanunit.py:436, in SanUnit.simulate(self, run, design_kwargs, cost_kwargs, **kwargs)
408 def simulate(self, run=True, design_kwargs={}, cost_kwargs={}, **kwargs):
409 '''
410 Converge mass and energy flows, design, and cost the unit.
411
(...) 434 `scipy.integrate.solve_ivp <https://docs.scipy.org/doc/scipy/reference/generated/scipy.integrate.solve_ivp.html>`_
435 '''
--> 436 super().simulate(run=run, design_kwargs=design_kwargs, cost_kwargs=cost_kwargs)
437 if self.isdynamic:
438 sys = self._mock_dyn_sys
File c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\biosteam\_unit.py:1464, in Unit.simulate(self, run, design_kwargs, cost_kwargs)
1462 for ps in self._specifications: ps.compile_path(self)
1463 self._load_stream_links()
-> 1464 self.run()
1465 self._summary(design_kwargs, cost_kwargs)
File c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\biosteam\_unit.py:48, in phenomena_based_run(self)
46 def phenomena_based_run(self):
47 if not (self._recycle_system and self._system.algorithm == 'Phenomena based'):
---> 48 Unit.run(self)
49 return
50 ins = self.ins
File c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\thermosteam\network.py:1560, in AbstractUnit.run(self)
1558 for i in self._outs: i.empty()
1559 return
-> 1560 self._run_with_specifications()
File c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\thermosteam\network.py:1576, in AbstractUnit._run_with_specifications(self)
1574 if self.run_after_specifications: self._run()
1575 else:
-> 1576 self._run()
File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\unit_operations\static\_excretion.py:85, in Excretion._run(self)
81 factor = 24 * 1e3 # from g per person per day to kg per hour
83 ur_N = (self.p_veg+self.p_anim)/factor*self.N_prot \
84 * self.N_exc*self.N_ur*not_wasted
---> 85 ur.imass['NH3'] = ur_N * self.N_ur_NH3
86 ur.imass['NonNH3'] = ur_N - ur.imass['NH3']
88 ur.imass['P'] = (self.p_veg*self.P_prot_v+self.p_anim*self.P_prot_a)/factor \
89 * self.P_exc*self.P_ur*not_wasted
File c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\thermosteam\indexer.py:544, in ChemicalIndexer.__setitem__(self, key, data)
543 def __setitem__(self, key, data):
--> 544 index, kind = self._chemicals._get_index_and_kind(key)
545 if kind is None:
546 reset_sparse_chemical_data(self.data, data)
File c:\Users\Yalin\Documents\Coding\QSDsan-platform\.venv\Lib\site-packages\thermosteam\_chemicals.py:1293, in CompiledChemicals._get_index_and_kind(self, key)
1286 # [int|None] Kind of index:
1287 # None - all
1288 # 0 - chemical
1289 # 1 - chemical group
1290 # 2 - nested chemical group
1291 # 3 - array
1292 if isa(key, str):
-> 1293 index = self.index(key)
1294 kind = 0 if isa(index, int) else 1
1295 elif isa(key, tuple):
File ~\Documents\Coding\QSDsan-platform\QSDsan\qsdsan\_components.py:753, in CompiledComponents.index(self, ID)
751 try: return self._index[ID]
752 except KeyError:
--> 753 raise UndefinedComponent(ID)
UndefinedComponent: 'NH3'
For those units, the best way is to look at the documentation/examples (e.g., for the above case, check the bwaise system referenced in the Excretion documentation).
↑ Back to top