{
"cells": [
{
"cell_type": "markdown",
"id": "fbe61cd8",
"metadata": {},
"source": [
"# Techno-Economic Analysis (TEA) \n",
"\n",
"*Click the badge below to try this tutorial interactively in your browser:*\n",
"\n",
"[](https://mybinder.org/v2/gh/QSD-Group/QSDsan-env/main?urlpath=git-pull%3Frepo%3Dhttps%253A%252F%252Fgithub.com%252FQSD-group%252FQSDsan%26urlpath%3Dlab%252Ftree%252FQSDsan%252Fdocs%252Fsource%252Ftutorials%26branch%3Dmain)\n",
"\n",
"*You can also run this tutorial in [Google Colab](https://colab.research.google.com). It takes a one-time setup per session: follow the [Colab instructions](https://qsdsan.readthedocs.io/en/latest/tutorials/index.html#run-in-colab).*\n",
"\n",
"- **Prepared by:**\n",
"\n",
" - [Yalin Li](https://github.com/yalinli2)\n",
"\n",
"- **Learning objectives.** After this tutorial, you will be able to:\n",
"\n",
" - Set up a `TEA` on a `System`\n",
" - Configure cost and financing parameters\n",
" - Read financial metrics: NPV, equivalent annual cost, and break-even price\n",
"\n",
"- **Prerequisites:** [6. System](https://qsdsan.readthedocs.io/en/latest/tutorials/6_System.html)\n",
"\n",
"- **Covered topics:**\n",
"\n",
" - 1. Using the TEA class\n",
" - 2. Developing your own TEA subclass\n",
" - 3. Cost indices\n",
"\n",
"> **Companion video.** A walkthrough of this tutorial is available on [YouTube](https://youtu.be/v3qNNZypTKY), presented by [Hannah Lohman](https://github.com/haclohman). Recorded against `QSDsan` v1.2.5. The concepts still apply, but if the code on screen differs from this notebook, follow the notebook.\n"
]
},
{
"cell_type": "markdown",
"id": "7125d72ca9ea",
"metadata": {},
"source": [
"\n",
"\n",
"## Setup\n",
"\n",
"Import `QSDsan` and confirm the installed version.\n"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "13d52da3",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:09.638445Z",
"iopub.status.busy": "2026-05-30T19:09:09.638445Z",
"iopub.status.idle": "2026-05-30T19:09:25.991322Z",
"shell.execute_reply": "2026-05-30T19:09:25.991322Z"
},
"scrolled": true
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"This tutorial was made with qsdsan v1.5.3.\n"
]
}
],
"source": [
"import qsdsan as qs\n",
"print(f'This tutorial was made with qsdsan v{qs.__version__}.')"
]
},
{
"cell_type": "markdown",
"id": "bebef2bf",
"metadata": {},
"source": [
"## 1. Using the `TEA` class "
]
},
{
"cell_type": "markdown",
"id": "s1-intro",
"metadata": {},
"source": [
"`qs.TEA` is built on `biosteam.TEA`. The `biosteam.TEA` base class is *abstract* (you cannot instantiate it directly), while `qs.TEA` is a ready-to-use subclass that adds a simplified capital-cost structure, unit-level operating costs, and annualized cost metrics.\n",
"\n",
"You can use `qs.TEA` directly (this section) or subclass it to add your own cost items (Section 2).\n",
"\n",
"
\n",
"\n",
"**Note.** `qs.TEA` used to be called `SimpleTEA`; the `Simple` prefix was dropped because it is the full-featured class, not a reduced one. `qs.SimpleTEA` remains as a deprecated alias.\n",
"\n",
"
"
]
},
{
"cell_type": "markdown",
"id": "s1-example-intro",
"metadata": {},
"source": [
"A techno-economic analysis (TEA) is a useful analysis to help you choose between technologies. So throughout this tutorial we compare two ways to treat the same municipal wastewater (4,000 m³/d, COD ≈ 430 mg/L):\n",
"\n",
"- an **aerobic** activated sludge process: dependable, but it spends electricity on aeration and produces a lot of sludge; and\n",
"- an **anaerobic** process: it recovers energy as biogas, but it must be heated and dosed with a little alkalinity.\n",
"\n",
"We build a realistic influent and let each plant actually *react*, which revisits the earlier tutorials: defining `Component`s and a `WasteStream`, subclassing `SanUnit` (a shared base plus two variants), and assembling a `System`. We will also evaluate the performance of the two systems using wastewater influent of different strengths.\n",
"\n",
"\n",
"\n",
"**Note.** Sizing, energy, and cost figures follow Metcalf & Eddy, *Wastewater Engineering* (5th ed.): typical municipal characteristics, methane and oxygen yields per unit COD (Ch. 8 and 10), and energy, chemical, and capital figures (Ch. 4 and 10). They are order-of-magnitude teaching values, not a design basis.\n",
"\n",
"
"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "s1-cmps",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:25.991322Z",
"iopub.status.busy": "2026-05-30T19:09:25.991322Z",
"iopub.status.idle": "2026-05-30T19:09:26.020984Z",
"shell.execute_reply": "2026-05-30T19:09:26.020984Z"
}
},
"outputs": [],
"source": [
"from qsdsan import SanUnit, WasteStream, Component, Components\n",
"from qsdsan.utils import get_digestion_rxns, compute_stream_COD\n",
"\n",
"# A small set of real (formula-bearing) components so the reactions below can balance:\n",
"# water, the gases, a representative wastewater organic, and biomass.\n",
"def make_cmp(ID, formula=None, search_ID=None, phase='l', size='Soluble',\n",
" deg='Undegradable', org=False):\n",
" return Component(ID, formula=formula, search_ID=search_ID, phase=phase,\n",
" particle_size=size, degradability=deg, organic=org)\n",
"\n",
"H2O = make_cmp('H2O', search_ID='H2O')\n",
"O2 = make_cmp('O2', search_ID='O2', phase='g', size='Dissolved gas')\n",
"CO2 = make_cmp('CO2', search_ID='CO2', phase='g', size='Dissolved gas')\n",
"NH3 = make_cmp('NH3', search_ID='NH3', phase='g', size='Dissolved gas')\n",
"CH4 = make_cmp('CH4', search_ID='CH4', phase='g', size='Dissolved gas', deg='Readily', org=True)\n",
"NaHCO3 = make_cmp('NaHCO3', search_ID='NaHCO3')\n",
"Substrate = make_cmp('Substrate', formula='C10H19O3N', deg='Readily', org=True) # representative WW organic\n",
"Biomass = make_cmp('Biomass', formula='C5H7O2N', phase='s', size='Particulate', deg='Slowly', org=True)\n",
"\n",
"cmps = Components([H2O, O2, CO2, NH3, CH4, NaHCO3, Substrate, Biomass])\n",
"for c in (NaHCO3, Substrate, Biomass):\n",
" c.copy_models_from(H2O, ('V', 'sigma', 'epsilon', 'kappa', 'Cn', 'mu'))\n",
"cmps.compile(ignore_inaccurate_molar_weight=True)\n",
"qs.set_thermo(cmps)"
]
},
{
"cell_type": "markdown",
"id": "s1-units-md",
"metadata": {},
"source": [
"**Building the two plants.** As the two systems share common properties, we will first construct a shared, abstract base `TreatmentPlant` class that holds the common properties. Then we will have subclasses of this `TreatmentPlant` that add reactions and costs specific to the two systems. This is the subclassing pattern from the advanced `SanUnit` tutorial.\n",
"\n",
"The aerobic plant grows biomass and oxidizes the rest of the COD; aeration must supply oxygen equal to the COD it oxidizes (that is what COD measures). The anaerobic plant digests the COD to biogas. We read the resulting oxygen demand, biomass (sludge), and methane straight from the reacted streams.\n",
"\n",
"\n",
"\n",
"**Note.** The base is declared `isabstract=True`. Without it, any class without a `_run` method will be treated as a static 1-in/1-out passthrough and link the unit's outlet to its feed, which would be wrong here.\n",
"\n",
"
"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "s1-base",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.020984Z",
"iopub.status.busy": "2026-05-30T19:09:26.020984Z",
"iopub.status.idle": "2026-05-30T19:09:26.028659Z",
"shell.execute_reply": "2026-05-30T19:09:26.028659Z"
}
},
"outputs": [],
"source": [
"class TreatmentPlant(SanUnit, isabstract=True):\n",
" \"\"\"Base plant: carries the installed and sludge disposal costs; subclasses add reactions and operating costs.\"\"\"\n",
" plant_capital = 5e6 # USD installed (M&E Ch. 4, small-plant order of magnitude)\n",
" sludge_disposal_cost = 0.10 # USD/kg dry solids (M&E Ch. 4)\n",
" # 'Plant' is already an installed cost, so its bare-module factor is 1; declaring it here\n",
" # avoids BioSTEAM's \"no defined bare-module factor\" warning (see the SanUnit tutorial).\n",
" _F_BM_default = {'Plant': 1.}\n",
"\n",
" def _cost(self):\n",
" self.baseline_purchase_costs['Plant'] = self.plant_capital"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "s1-aerobic",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.030929Z",
"iopub.status.busy": "2026-05-30T19:09:26.030929Z",
"iopub.status.idle": "2026-05-30T19:09:26.037070Z",
"shell.execute_reply": "2026-05-30T19:09:26.037070Z"
}
},
"outputs": [],
"source": [
"class AerobicPlant(TreatmentPlant):\n",
" \"\"\"Activated sludge: grows biomass and oxidizes the rest (aeration O2 = COD oxidized).\"\"\"\n",
" _N_ins = 1 # wastewater\n",
" _N_outs = 2 # treated effluent, waste sludge\n",
" X_growth = 0.40 # fraction of biodegradable COD converted to biomass\n",
" X_oxid = 0.95 # fraction of the remaining substrate oxidized to CO2\n",
" O2_per_kWh = 1.2 # aeration efficiency, kg O2 per kWh (M&E)\n",
"\n",
" def __init__(self, *args, **kwargs):\n",
" super().__init__(*args, **kwargs)\n",
" self.growth_rxns = get_digestion_rxns(self.components, 0., self.X_growth, 'Biomass', 1.)\n",
" self._mixed = WasteStream(f'{self.ID}_mixed') # react here, then split to the outlets\n",
"\n",
" def _run(self):\n",
" eff, sludge = self.outs\n",
" m = self._mixed; m.copy_like(self.ins[0])\n",
" self.growth_rxns(m.mol) # substrate -> biomass\n",
" oxidized = m.imass['Substrate']*self.X_oxid\n",
" self._O2_demand = oxidized*self.components.Substrate.i_COD*24 # kg O2/d == COD oxidized\n",
" m.imass['Substrate'] -= oxidized # oxidized COD leaves as CO2\n",
" sludge.empty(); sludge.phase = 's'; sludge.imass['Biomass'] = m.imass['Biomass']\n",
" m.imass['Biomass'] = 0\n",
" eff.copy_like(m)\n",
"\n",
" def _cost(self):\n",
" super()._cost()\n",
" self.power_utility(self._O2_demand/self.O2_per_kWh/24) # kW for aeration\n",
" self.add_OPEX = {'Sludge disposal': self.outs[1].F_mass*self.sludge_disposal_cost}"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "s1-anaerobic",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.037070Z",
"iopub.status.busy": "2026-05-30T19:09:26.037070Z",
"iopub.status.idle": "2026-05-30T19:09:26.046819Z",
"shell.execute_reply": "2026-05-30T19:09:26.046819Z"
}
},
"outputs": [],
"source": [
"class AnaerobicPlant(TreatmentPlant):\n",
" \"\"\"Anaerobic digestion: recovers biogas, but needs heating (a utility) and alkalinity.\"\"\"\n",
" _N_ins = 2 # wastewater, sodium bicarbonate\n",
" _N_outs = 3 # treated effluent, waste sludge, biogas\n",
" X_biogas = 0.86 # fraction of biodegradable COD converted to biogas\n",
" X_growth = 0.05 # fraction converted to biomass\n",
" CH4_LHV = 50000. # kJ/kg methane (lower heating value)\n",
" energy_price = 5/1e6 # USD/kJ ($5 per 10^6 kJ, M&E)\n",
" T_op = 273.15 + 35 # K, operating temperature (35 C)\n",
" HX_eff = 0.80 # heat recovery efficiency\n",
" NaHCO3_dose = 0.10 # kg/m3 alkalinity\n",
"\n",
" def __init__(self, *args, **kwargs):\n",
" super().__init__(*args, **kwargs)\n",
" self.rxns = get_digestion_rxns(self.components, self.X_biogas, self.X_growth, 'Biomass', 1.)\n",
" self._mixed = WasteStream(f'{self.ID}_mixed')\n",
"\n",
" def _run(self):\n",
" ww, chem = self.ins\n",
" eff, sludge, biogas = self.outs\n",
" chem.empty(); chem.imass['NaHCO3'] = self.NaHCO3_dose*ww.F_vol # kg/hr (dose * m3/hr)\n",
" m = self._mixed; m.copy_like(ww)\n",
" self.rxns(m.mol) # substrate -> biogas + biomass\n",
" biogas.empty(); biogas.phase = 'g'\n",
" biogas.imass['CH4'] = m.imass['CH4']; biogas.imass['CO2'] = m.imass['CO2']\n",
" # value the biogas by its methane energy only (the CO2 in it is inert)\n",
" biogas.price = (biogas.imass['CH4']*self.CH4_LHV*self.energy_price)/biogas.F_mass \\\n",
" if biogas.F_mass else 0.\n",
" sludge.empty(); sludge.phase = 's'; sludge.imass['Biomass'] = m.imass['Biomass']\n",
" m.imass['CH4'] = m.imass['CO2'] = m.imass['Biomass'] = m.imass['NH3'] = 0\n",
" eff.copy_like(m)\n",
"\n",
" def _design(self):\n",
" # Heating the digester is an energy demand that scales with flow, so it is a\n",
" # utility (like the aerobic plant's aeration), not a fixed operating cost.\n",
" inf = self.ins[0]\n",
" duty = inf.F_mass * inf.Cp * (self.T_op - inf.T) # kJ/hr of sensible heat\n",
" if duty > 0:\n",
" self.add_heat_utility(duty, inf.T, T_out=self.T_op, heat_transfer_efficiency=self.HX_eff)\n",
"\n",
" def _cost(self):\n",
" super()._cost()\n",
" self.add_OPEX = {'Sludge disposal': self.outs[1].F_mass*self.sludge_disposal_cost}"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "s1-build",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.046819Z",
"iopub.status.busy": "2026-05-30T19:09:26.046819Z",
"iopub.status.idle": "2026-05-30T19:09:26.552570Z",
"shell.execute_reply": "2026-05-30T19:09:26.552570Z"
}
},
"outputs": [
{
"data": {
"image/svg+xml": [
"\n",
"\n",
"\n",
"\n",
"\n",
"137711079969:c->137710320994:w\n",
"\n",
"\n",
" aer effluent\n",
"\n",
"\n",
"\n",
"\n",
"\n",
"137711079969:c->137710320554:w\n",
"\n",
"\n",
" aer sludge\n",
"\n",
"\n",
"\n",
"\n",
"\n",
"137710320954:e->137711079969:c\n",
"\n",
"\n",
" ww aer\n",
"\n",
"\n",
"\n",
"\n",
"\n",
"137711079969\n",
"\n",
"\n",
"aer\n",
"Aerobic plant\n",
"\n",
"\n",
"\n",
"\n",
"\n",
"137710320954\n",
"\n",
"\n",
"\n",
"\n",
"137710320994\n",
"\n",
"\n",
"\n",
"\n",
"137710320554\n",
"\n",
"\n",
"\n",
""
],
"text/plain": [
""
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"# Build a realistic municipal influent (COD ~430 mg/L, 20 C) and a system for each plant.\n",
"def make_influent(ID, COD=430.):\n",
" ww = WasteStream(ID, T=273.15+20); ww.ivol['H2O'] = 4000/24 # 4,000 m3/d at 20°C\n",
" ww.imass['Substrate'] = (COD/1000 * 4000/24)/Substrate.i_COD # set the COD\n",
" return ww\n",
"\n",
"qs.main_flowsheet.set_flowsheet('tea_compare')\n",
"aer = AerobicPlant('aer', ins=make_influent('ww_aer'), outs=('aer_effluent', 'aer_sludge'))\n",
"aer_sys = qs.System('aer_sys', path=(aer,))\n",
"\n",
"NaHCO3_feed = WasteStream('NaHCO3_feed', price=0.90) # USD/kg (Ch. 10)\n",
"ana = AnaerobicPlant('ana', ins=(make_influent('ww_ana'), NaHCO3_feed),\n",
" outs=('ana_effluent', 'ana_sludge', 'biogas'))\n",
"ana_sys = qs.System('ana_sys', path=(ana,))\n",
"\n",
"qs.PowerUtility.price = 0.08 # USD/kWh electricity (M&E Ch. 4)\n",
"aer_sys.simulate(); ana_sys.simulate()\n",
"aer_sys.diagram() # default format; pass format='html' for an interactive diagram"
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "s1-results",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.552570Z",
"iopub.status.busy": "2026-05-30T19:09:26.552570Z",
"iopub.status.idle": "2026-05-30T19:09:26.579188Z",
"shell.execute_reply": "2026-05-30T19:09:26.579188Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"aerobic effluent COD: 12.9 mg/L\n",
"anaerobic effluent COD: 38.7 mg/L\n",
"Aerobic plant Units aer\n",
"Electricity Power kW 34\n",
" Cost USD/hr 2.72\n",
"Purchase cost Plant USD 5e+06\n",
"Total purchase cost USD 5e+06\n",
"Utility cost USD/hr 2.72\n",
"Sludge disposal USD/hr 1.44\n",
"Anaerobic plant Units ana\n",
"Low pressure steam Duty kJ/hr 1.31e+07\n",
" Flow kmol/hr 337\n",
" Cost USD/hr 80.2\n",
"Purchase cost Plant USD 5e+06\n",
"Total purchase cost USD 5e+06\n",
"Utility cost USD/hr 80.2\n",
"Sludge disposal USD/hr 0.18\n"
]
}
],
"source": [
"# Each plant reacts the influent; the design and costs come from the reacted streams.\n",
"print('aerobic effluent COD: ', round(compute_stream_COD(aer.outs[0], 'mg/L'), 1), 'mg/L')\n",
"print('anaerobic effluent COD:', round(compute_stream_COD(ana.outs[0], 'mg/L'), 1), 'mg/L')\n",
"print(aer.results())\n",
"print(ana.results())"
]
},
{
"cell_type": "markdown",
"id": "s1-1",
"metadata": {},
"source": [
"### 1.1. What costs does `qs.TEA` include?\n",
"\n",
"`qs.TEA` sorts every cash item into a few categories. Our two plants populate different ones:\n",
"\n",
"| Cost | Aerobic plant | Anaerobic plant |\n",
"| --- | --- | --- |\n",
"| Capital | installed plant cost | installed plant cost |\n",
"| Utility (VOC) | aeration electricity | heating steam |\n",
"| Material (VOC) | none | alkalinity NaHCO₃ |\n",
"| Unit `add_OPEX` | sludge disposal | sludge disposal |\n",
"| Maintenance + labor (FOC) | not set (0) | not set (0) |\n",
"| Sales | none | biogas (methane energy) |\n",
"\n",
"Both plants spend energy (a utility) and dispose of sludge (an `add_OPEX`); only the anaerobic plant buys a chemical and earns a product credit. Neither sets `annual_maintenance` or `annual_labor`, so the conventional fixed operating cost is zero. `add_OPEX` is shown on its own line above, though `qs.TEA` still counts it within FOC (see 1.2). We attach a `TEA` to each system:"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "s1-1-code",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.582196Z",
"iopub.status.busy": "2026-05-30T19:09:26.581196Z",
"iopub.status.idle": "2026-05-30T19:09:26.637373Z",
"shell.execute_reply": "2026-05-30T19:09:26.637373Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"TEA: aer_sys\n",
"NPV : -5,454,773 USD at 5.0% discount rate\n",
"TEA: ana_sys\n",
"NPV : -14,995,309 USD at 5.0% discount rate\n"
]
}
],
"source": [
"tea_aer = qs.TEA(aer_sys, discount_rate=0.05, lifetime=20)\n",
"tea_ana = qs.TEA(ana_sys, discount_rate=0.05, lifetime=20)\n",
"tea_aer.show()\n",
"tea_ana.show()"
]
},
{
"cell_type": "markdown",
"id": "s1-2",
"metadata": {},
"source": [
"### 1.2. How the cost components add up\n",
"\n",
"**Capital Expenditure (CAPEX).** By default `qs.TEA` collapses the capital hierarchy, so every level equals the installed equipment cost:\n",
"\n",
"`installed_equipment_cost` = `DPI` = `TDC` = `FCI` = `TCI`\n",
"\n",
"(no indirect markups until you subclass, as in Section 2).\n",
"\n",
"**Operating Expenditure (OPEX).** The annual operating cost (AOC) is the sum of fixed and variable operating costs (FOC and VOC):\n",
"\n",
"$$FOC = FCI \\times annual\\_maintenance + annual\\_labor + add\\_OPEX$$\n",
"\n",
"$$VOC = material\\ cost + utility\\ cost$$\n",
"\n",
"$$AOC = FOC + VOC$$\n",
"\n",
"That FOC expression is how `qs.TEA` defines `_FOC` **by default**: `annual_maintenance` is a *fraction of FCI* (so maintenance is the only piece that scales with capital), `annual_labor` is a flat amount in USD/yr, and `add_OPEX` collects the extra operating costs declared on the units (plus any `system_add_OPEX`). The VOC is the cost of material inputs plus utilities. Because maintenance is calculated from FCI, the capital markups added in Section 2 also raise the FOC.\n",
"\n",
"Both of our plants leave `annual_maintenance` and `annual_labor` at 0, so their conventional fixed cost is zero and the whole default FOC is just the units' `add_OPEX` (sludge disposal).\n",
"\n",
"\n",
"\n",
"**Where does an operating cost belong?** By default `qs.TEA` counts `add_OPEX` as part of FOC, which is convenient but treats it as fixed. Strictly, a cost belongs wherever it behaves:\n",
"\n",
"- a genuinely **fixed** cost (a maintenance contract, salaried staff): use `annual_maintenance` (a fraction of FCI) or `annual_labor`;\n",
"- a cost that **scales with throughput** (a consumable bought, or a waste disposed of): give the stream a **price**, so a priced feed adds to `material_cost` (VOC) and a priced product or waste adds to `sales` (a disposal cost is simply a negative price);\n",
"- `add_OPEX` is the convenient catch-all in between.\n",
"\n",
"Sludge disposal scales with throughput, so it could instead be a negative price on the sludge stream; we keep it as `add_OPEX` here for simplicity. If you want to categorize operating costs differently (for example, to move `add_OPEX` out of FOC, or to split it into fixed and variable parts), you can override `_FOC` (or the `VOC` property) in a subclass; see
Section 2.\n",
"\n",
"
\n",
"\n",
"Laying the two plants side by side shows how differently their costs are structured:"
]
},
{
"cell_type": "code",
"execution_count": 9,
"id": "s1-2-code",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.637373Z",
"iopub.status.busy": "2026-05-30T19:09:26.637373Z",
"iopub.status.idle": "2026-05-30T19:09:26.644711Z",
"shell.execute_reply": "2026-05-30T19:09:26.644711Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"all values in USD (CAPEX) or USD/yr\n",
" aerobic: CAPEX 5,000,000 | material 0 | utility 23,856 | add_OPEX 12,636 | sales 0 | AOC 36,492\n",
" anaerobic: CAPEX 5,000,000 | material 131,403 | utility 702,902 | add_OPEX 1,579 | sales 33,835 | AOC 835,884\n"
]
}
],
"source": [
"def breakdown(name, tea):\n",
" print(f'{name:>10}: CAPEX {tea.CAPEX:>11,.0f} | material {tea.material_cost:>11,.0f} | '\n",
" f'utility {tea.utility_cost:>11,.0f} | add_OPEX {tea.unit_add_OPEX:>9,.0f} | '\n",
" f'sales {tea.sales:>9,.0f} | AOC {tea.AOC:>11,.0f}')\n",
"\n",
"print('all values in USD (CAPEX) or USD/yr')\n",
"breakdown('aerobic', tea_aer)\n",
"breakdown('anaerobic', tea_ana)"
]
},
{
"cell_type": "markdown",
"id": "s1-3",
"metadata": {},
"source": [
"### 1.3. Parameters and their defaults\n",
"\n",
"The most commonly adjusted `qs.TEA` parameters are below; run `?qs.TEA` or see the [API docs](https://qsdsan.readthedocs.io/en/latest/api/major_classes/TEA.html) for the complete list.\n",
"\n",
"| Parameter | Default | Meaning |\n",
"| --- | --- | --- |\n",
"| `discount_rate` | `0.05` | Discount rate for the cash flow analysis (equals IRR when NPV = 0). |\n",
"| `income_tax` | `0.` | Combined tax rate applied to net earnings. |\n",
"| `lifetime` | `10` | Operating lifetime, in years (the depreciation schedule must fit within it; see 1.5). |\n",
"| `start_year` | current year | Calendar year operation begins. |\n",
"| `uptime_ratio` | `1.` | Fraction of the year the system operates. |\n",
"| `annual_maintenance` | `0.` | Maintenance cost as a fraction of FCI. |\n",
"| `annual_labor` | `0.` | Labor cost, USD/yr. |\n",
"| `system_add_OPEX` | `{}` | Extra system-level operating cost on top of each unit's `add_OPEX`. |\n",
"| `depreciation` | `'SL'` | Depreciation schedule: `'SL'` (default), `'DDB'`, `'SYD'`, `'MACRS5'`, `'MACRS7'`, ...; see 1.5. |\n",
"| `CEPCI` | `None` | Cost index for equipment scaling; leave as is, or pass a year's value (Section 3). |\n",
"| `CAPEX`, `lang_factor` | `0.`, `None` | Alternative ways to set the installed cost (see Section 2). |\n",
"\n",
"Changing a parameter and re-reading a metric takes effect immediately:"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "s1-3-code",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.646793Z",
"iopub.status.busy": "2026-05-30T19:09:26.646793Z",
"iopub.status.idle": "2026-05-30T19:09:26.650530Z",
"shell.execute_reply": "2026-05-30T19:09:26.650530Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"discount_rate = 3% -> aerobic NPV = -5,542,911 USD\n",
"discount_rate = 5% -> aerobic NPV = -5,454,773 USD\n",
"discount_rate = 10% -> aerobic NPV = -5,310,678 USD\n"
]
}
],
"source": [
"for r in (0.03, 0.05, 0.10):\n",
" tea_aer.discount_rate = r\n",
" print(f'discount_rate = {r:.0%} -> aerobic NPV = {tea_aer.NPV:,.0f} USD')\n",
"tea_aer.discount_rate = 0.05 # restore"
]
},
{
"cell_type": "markdown",
"id": "s1-4",
"metadata": {},
"source": [
"### 1.4. How the financial metrics are computed\n",
"\n",
"All metrics come from a **discounted cash flow (DCF)** analysis: each year's capital, depreciation, taxes, operating costs, and sales are discounted back to the present at the discount rate. `qs.TEA` also schedules equipment replacement at the end of each unit's lifetime. The full year-by-year table is available with `get_cashflow_table()` (values in million USD):"
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "s1-4-code1",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.650530Z",
"iopub.status.busy": "2026-05-30T19:09:26.650530Z",
"iopub.status.idle": "2026-05-30T19:09:26.661037Z",
"shell.execute_reply": "2026-05-30T19:09:26.661037Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" Depreciable capital [MM$] | \n",
" Fixed capital investment [MM$] | \n",
" Working capital [MM$] | \n",
" Depreciation [MM$] | \n",
" Loan [MM$] | \n",
"
\n",
" \n",
" \n",
" \n",
" | 2024 | \n",
" 0 | \n",
" 0 | \n",
" 0 | \n",
" 0 | \n",
" 0 | \n",
"
\n",
" \n",
" | 2025 | \n",
" 5 | \n",
" 5 | \n",
" 0 | \n",
" 0 | \n",
" 0 | \n",
"
\n",
" \n",
" | 2026 | \n",
" 0 | \n",
" 0 | \n",
" 0 | \n",
" 0.25 | \n",
" 0 | \n",
"
\n",
" \n",
" | 2027 | \n",
" 0 | \n",
" 0 | \n",
" 0 | \n",
" 0.25 | \n",
" 0 | \n",
"
\n",
" \n",
" | 2028 | \n",
" 0 | \n",
" 0 | \n",
" 0 | \n",
" 0.25 | \n",
" 0 | \n",
"
\n",
" \n",
" | 2029 | \n",
" 0 | \n",
" 0 | \n",
" 0 | \n",
" 0.25 | \n",
" 0 | \n",
"
\n",
" \n",
"
\n",
"
"
],
"text/plain": [
" Depreciable capital [MM$] Fixed capital investment [MM$] Working capital [MM$] Depreciation [MM$] Loan [MM$]\n",
"2024 0 0 0 0 0\n",
"2025 5 5 0 0 0\n",
"2026 0 0 0 0.25 0\n",
"2027 0 0 0 0.25 0\n",
"2028 0 0 0 0.25 0\n",
"2029 0 0 0 0.25 0"
]
},
"execution_count": 11,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"tea_ana.get_cashflow_table().iloc[:6, :5] # first few years and columns; you can drop the slice for the full table"
]
},
{
"cell_type": "markdown",
"id": "s1-4-md2",
"metadata": {},
"source": [
"The most cost-effective option has the higher (less negative) NPV. Here a large part of the cost comes from equipment purchase, represented by the equivalent annual cost (EAC; levelized annual cost over the lifetime). From the treatment plant's point of view, they can charge a **treatment fee** so that they can break even (i.e., not lose or make money). This treatment fee can be calculated using the `solve_price` method, indicating the price to charge per m³ of wastewater received for NPV to reach zero. For the anaerobic plant, the revenue from biogas also needs to be considered:"
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "s1-4-code2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.662789Z",
"iopub.status.busy": "2026-05-30T19:09:26.662789Z",
"iopub.status.idle": "2026-05-30T19:09:26.667436Z",
"shell.execute_reply": "2026-05-30T19:09:26.667436Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
" aerobic: NPV -5,454,773 USD | EAC 437,705 USD/yr | break-even fee 0.30 USD/m3\n",
" anaerobic: NPV -14,995,309 USD | EAC 1,237,097 USD/yr | break-even fee 0.83 USD/m3\n"
]
}
],
"source": [
"Q_annual = 4000 * 365 # m3/yr\n",
"for name, tea, U in (('aerobic', tea_aer, aer), ('anaerobic', tea_ana, ana)):\n",
" fee = -tea.solve_price(U.ins[0]) * 1000 # USD/m3 (negative price = paid to receive)\n",
" print(f'{name:>10}: NPV {tea.NPV:>14,.0f} USD | EAC {tea.EAC:>12,.0f} USD/yr | '\n",
" f'break-even fee {fee:5.2f} USD/m3')"
]
},
{
"cell_type": "markdown",
"id": "s1-4-md3",
"metadata": {},
"source": [
"**Impacts of wastewater strength.** As shown by the less negative NPV and smaller break-even fee, the aerobic plant is more cost effective for this municipal wastewater. The reason is energy: the anaerobic plant must heat the whole flow to ~35 °C (a cost set by *flow*, not strength), while its biogas revenue scales with *COD*. Dilute municipal wastewater simply has too little COD for the biogas to pay for the heating. Aeration, meanwhile, is cheap when there is little COD to oxidize.\n",
"\n",
"This balance flips for strong (for example, industrial) wastewater. Let's sweep the influent COD and see where anaerobic overtakes aerobic:"
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "s1-4-code3",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.667436Z",
"iopub.status.busy": "2026-05-30T19:09:26.667436Z",
"iopub.status.idle": "2026-05-30T19:09:26.692118Z",
"shell.execute_reply": "2026-05-30T19:09:26.692118Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" COD (mg/L) | \n",
" aerobic NPV | \n",
" anaerobic NPV | \n",
" winner | \n",
"
\n",
" \n",
" \n",
" \n",
" | 0 | \n",
" 430 | \n",
" -5454773 | \n",
" -14995309 | \n",
" aerobic | \n",
"
\n",
" \n",
" | 1 | \n",
" 1000 | \n",
" -6057611 | \n",
" -14462724 | \n",
" aerobic | \n",
"
\n",
" \n",
" | 2 | \n",
" 2000 | \n",
" -7115223 | \n",
" -13528365 | \n",
" aerobic | \n",
"
\n",
" \n",
" | 3 | \n",
" 4000 | \n",
" -9230445 | \n",
" -11659647 | \n",
" aerobic | \n",
"
\n",
" \n",
" | 4 | \n",
" 8000 | \n",
" -13460890 | \n",
" -7922212 | \n",
" anaerobic | \n",
"
\n",
" \n",
"
\n",
"
"
],
"text/plain": [
" COD (mg/L) aerobic NPV anaerobic NPV winner\n",
"0 430 -5454773 -14995309 aerobic\n",
"1 1000 -6057611 -14462724 aerobic\n",
"2 2000 -7115223 -13528365 aerobic\n",
"3 4000 -9230445 -11659647 aerobic\n",
"4 8000 -13460890 -7922212 anaerobic"
]
},
"execution_count": 13,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"import pandas as pd\n",
"rows = []\n",
"for COD in (430, 1000, 2000, 4000, 8000):\n",
" a = AerobicPlant(f'a{COD}', ins=make_influent(f'wa{COD}', COD), outs=('', ''))\n",
" n = AnaerobicPlant(f'n{COD}', ins=(make_influent(f'wn{COD}', COD),\n",
" WasteStream(f'c{COD}', price=0.90)), outs=('', '', ''))\n",
" a_sys = qs.System(f'as{COD}', path=(a,)); n_sys = qs.System(f'ns{COD}', path=(n,))\n",
" a_sys.simulate(); n_sys.simulate()\n",
" ta = qs.TEA(a_sys, discount_rate=0.05, lifetime=20)\n",
" tn = qs.TEA(n_sys, discount_rate=0.05, lifetime=20)\n",
" rows.append((COD, round(ta.NPV), round(tn.NPV), 'aerobic' if ta.NPV > tn.NPV else 'anaerobic'))\n",
"\n",
"pd.DataFrame(rows, columns=['COD (mg/L)', 'aerobic NPV', 'anaerobic NPV', 'winner'])"
]
},
{
"cell_type": "markdown",
"id": "s1-4-md4",
"metadata": {},
"source": [
"Our analysis shows that **anaerobic treatment favors stronger wastewater** (high COD per unit flow), which is why it is used for industrial effluents and for digesting sludge, while **aerobic treatment favors dilute municipal wastewater**. A TEA turns that rule of thumb into a number.\n",
"\n",
"\n",
"\n",
"**A note on IRR / DCFROR.** `tea.solve_IRR()` returns the **internal rate of return**, also called the **discounted cash flow rate of return (DCFROR)**: the discount rate at which NPV is zero. It is meaningful for revenue-positive *investments*. Both plants here are cost centers (NPV is negative at every realistic rate), so we compare them with NPV and EAC and use `solve_price` for a break-even fee.\n",
"\n",
"
"
]
},
{
"cell_type": "markdown",
"id": "s1-4-md5",
"metadata": {},
"source": [
"**Annualized capital cost.** `qs.TEA` also exposes two related annual capital metrics:\n",
"\n",
"- `annualized_equipment_cost` sums each equipment's installed cost annualized over its *own* lifetime, $\\frac{installed\\ cost}{(1-(1+r)^{-lifetime})}$. Because it uses the equipment lifetime, it is *optimistic* when the project lifetime is not a multiple of the equipment lifetime: it implicitly salvages the value left at project end.\n",
"- `annualized_CAPEX` instead comes from the cash flow itself (`annual net earnings − annualized NPV`). It charges the actual replacements and assumes no salvage value, and is the term `EAC` uses.\n",
"\n",
"For our base plants the equipment is assumed to last the whole project, so the two coincide. Give the equipment a shorter life (one replacement within the project) and they diverge: A unit's equipment lifetime is set through its `lifetime` attribute (used below), which is an alias for `equipment_lifetime`; it is distinct from the project `lifetime` passed to `qs.TEA`."
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "s1-4-code4",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.695130Z",
"iopub.status.busy": "2026-05-30T19:09:26.694131Z",
"iopub.status.idle": "2026-05-30T19:09:26.718024Z",
"shell.execute_reply": "2026-05-30T19:09:26.717349Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"equipment lasts the project (no replacement):\n",
" annualized_equipment_cost : 401,213 USD/yr\n",
" annualized_CAPEX : 401,213 USD/yr\n",
"\n",
"15-year equipment life (replaced once, at year 15):\n",
" annualized_equipment_cost : 481,711 USD/yr (optimistic)\n",
" annualized_CAPEX : 585,013 USD/yr\n"
]
}
],
"source": [
"# Base case: the plant equipment is assumed to last the full 20-year project, so the two agree.\n",
"print('equipment lasts the project (no replacement):')\n",
"print(f' annualized_equipment_cost : {tea_aer.annualized_equipment_cost:,.0f} USD/yr')\n",
"print(f' annualized_CAPEX : {tea_aer.annualized_CAPEX:,.0f} USD/yr')\n",
"\n",
"# Now give the equipment a 15-year life, so it is replaced once inside the 20-year project.\n",
"# The cash flow charges that replacement, but annualized_equipment_cost still annualizes over\n",
"# the 15-year equipment life (implicitly salvaging the value left at year 20):\n",
"aer.lifetime = 15 # the unit's equipment lifetime (`lifetime` aliases `equipment_lifetime`)\n",
"aer_sys.simulate()\n",
"tea_15 = qs.TEA(aer_sys, discount_rate=0.05, lifetime=20)\n",
"print('\\n15-year equipment life (replaced once, at year 15):')\n",
"print(f' annualized_equipment_cost : {tea_15.annualized_equipment_cost:,.0f} USD/yr (optimistic)')\n",
"print(f' annualized_CAPEX : {tea_15.annualized_CAPEX:,.0f} USD/yr')\n",
"aer.lifetime = {}; aer_sys.simulate() # restore the base case for later sections\n",
"\n",
"# You can export the full system, design, and TEA results to Excel:\n",
"# aer_sys.save_report(file='aer_sys.xlsx')"
]
},
{
"cell_type": "markdown",
"id": "ac9124e8",
"metadata": {},
"source": [
"\n",
"\n",
"**Exporting results.** `save_report` writes the whole system, including stream tables, unit designs, costs, and utilities, to a single Excel workbook, e.g. `aer_sys.save_report('aer_sys.xlsx')` (commented out in the cell above so no file is written here). See the *Inspecting and exporting results* section of [6. System](https://qsdsan.readthedocs.io/en/latest/tutorials/6_System.html) for the full workflow.\n",
"\n",
"
"
]
},
{
"cell_type": "markdown",
"id": "s1-5",
"metadata": {},
"source": [
"### 1.5. Depreciation and taxes\n",
"\n",
"Capital is spent once, up front, but for tax purposes its cost is written off gradually as **depreciation**: a non-cash expense that lowers taxable income (and therefore income tax) over a set schedule. Depreciation does not change the total pre-tax cash; it changes *when* tax is paid, which matters once those payments are discounted.\n",
"\n",
"`qs.TEA` takes `depreciation` as a string: `'SL'` (straight line, the default), `'DDB'` (double-declining balance), `'SYD'` (sum-of-years-digits), or a MACRS schedule (`'MACRS3'`, `'MACRS5'`, `'MACRS7'`, `'MACRS10'`, ...). One constraint: the schedule must fit within the lifetime. Straight line spans the whole lifetime, so it always fits; MACRS schedules run one year longer than their name (the IRS half-year convention), so `'MACRS5'` is a 6-year schedule (needs `lifetime >= 6`) and `'MACRS7'` needs `lifetime >= 8`.\n",
"\n",
"The catch: depreciation only changes the result when there is **taxable income** to shield. Our plants run with `income_tax = 0`, so the schedule makes no difference at all:"
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "s1-5-code1",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.718024Z",
"iopub.status.busy": "2026-05-30T19:09:26.718024Z",
"iopub.status.idle": "2026-05-30T19:09:26.725938Z",
"shell.execute_reply": "2026-05-30T19:09:26.725938Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"SL : NPV = -5,454,773 USD\n",
"MACRS5 : NPV = -5,454,773 USD\n",
"MACRS7 : NPV = -5,454,773 USD\n"
]
}
],
"source": [
"for dep in ('SL', 'MACRS5', 'MACRS7'):\n",
" tea = qs.TEA(aer_sys, discount_rate=0.05, lifetime=20, income_tax=0., depreciation=dep)\n",
" print(f'{dep:7}: NPV = {tea.NPV:,.0f} USD') # identical: with no tax, depreciation is irrelevant"
]
},
{
"cell_type": "markdown",
"id": "s1-5-md2",
"metadata": {},
"source": [
"Now suppose the utility charges a treatment fee that makes the plant profitable, and it pays income tax. We add the fee by giving the influent a negative price (the plant is *paid* to receive it) and set a 21% income tax. Accelerated schedules (MACRS) now raise the NPV: they take larger deductions in the early years and defer tax to later years, when those payments are worth less in present value."
]
},
{
"cell_type": "code",
"execution_count": 16,
"id": "s1-5-code2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.727948Z",
"iopub.status.busy": "2026-05-30T19:09:26.727948Z",
"iopub.status.idle": "2026-05-30T19:09:26.733008Z",
"shell.execute_reply": "2026-05-30T19:09:26.733008Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"SL : NPV = 3,905,892 USD\n",
"MACRS5 : NPV = 4,140,476 USD\n",
"MACRS7 : NPV = 4,127,112 USD\n"
]
}
],
"source": [
"aer.ins[0].price = -0.0006 # USD/kg influent: a treatment fee of ~0.6 USD/m3 (negative = paid to treat)\n",
"aer_sys.simulate()\n",
"for dep in ('SL', 'MACRS5', 'MACRS7'):\n",
" tea = qs.TEA(aer_sys, discount_rate=0.05, lifetime=20, income_tax=0.21, depreciation=dep)\n",
" print(f'{dep:7}: NPV = {tea.NPV:,.0f} USD')\n",
"aer.ins[0].price = 0. # restore the base case"
]
},
{
"cell_type": "markdown",
"id": "s1-5-md3",
"metadata": {},
"source": [
"The fastest schedule (`MACRS5`) gives the highest NPV, `MACRS7` a little less, and straight line the least. The effect is real but second-order next to the capital and operating costs themselves, and it disappears entirely without taxable income. So for the cost-center comparison in this tutorial, the depreciation choice does not change which plant wins."
]
},
{
"cell_type": "markdown",
"id": "s1-agile-note",
"metadata": {},
"source": [
"\n",
"\n",
"**Operating over the year.** What if the system operates differently across the year, such as seasonal load, day and night, or feedstock switching? An AgileSystem rolls those operating modes into one annualized TEA weighted by operating hours, rather than a single steady-state snapshot. See [Operational flexibility in Tutorial 6](6_System.ipynb#4.-Operational-flexibility).\n",
"\n",
"
"
]
},
{
"cell_type": "markdown",
"id": "23b69594",
"metadata": {},
"source": [
"## 2. Developing your own `TEA` subclass "
]
},
{
"cell_type": "markdown",
"id": "ab418cf0",
"metadata": {},
"source": [
"By default, `qs.TEA` assumes `installed_equipment_cost` (the sum of the `installed_cost` of every unit in the system) equals the direct permanent investment (DPI), the total depreciable capital (TDC), and the fixed capital investment (FCI). In a full plant estimate they are *not* equal: the installed equipment cost is only the starting point, and several markups are added on the way to the capital you actually invest.\n",
"\n",
"The standard chemical-engineering build-up (Seider et al., *Product and Process Design Principles*, Ch. 16) goes:\n",
"\n",
"| Level | `qs.TEA` attribute | What it adds on top of the previous level |\n",
"| --- | --- | --- |\n",
"| Purchased equipment cost | `purchase_cost` | the vendor (free on board, f.o.b.) price of the equipment |\n",
"| Installed (bare-module) cost | `installed_equipment_cost` | installation: piping, foundations, electrical, instruments, labor, freight, insurance, and overheads (a bare-module factor applied to the purchase cost) |\n",
"| Direct permanent investment | `DPI` | site preparation and service facilities |\n",
"| Total depreciable capital | `TDC` | contingency and contractor fee (~18% of DPI) |\n",
"| Fixed capital investment | `FCI` | nondepreciable, one-time items: land, royalties, startup |\n",
"| Total capital investment | `TCI`, returned by `CAPEX` | working capital |\n",
"\n",
"So `tea.CAPEX` is the top of the chain (TCI): the total one-time capital you put in. By default, `qs.TEA` assumes DPI = TDC = FCI = installed cost, and TCI = FCI when working capital is zero."
]
},
{
"cell_type": "markdown",
"id": "464f6a77",
"metadata": {},
"source": [
"To customize DPI, TDC, and FCI calculations, you can make a subclass `TEA` to replace the default `_DPI`, `_TDC`, `_FCI` methods using a certain basis. The same also applies for `_FOC`.\n",
"\n",
"- `_DPI` direct permanent investment, calculated using `installed_equipment_cost`;\n",
"- `_TDC` total depreciable capital, calculated using `_DPI`;\n",
"- `_FCI` fixed capital investment, calculated using `_TDC`;\n",
"- `_FOC` fixed operating cost, calculated using `_FCI`."
]
},
{
"cell_type": "markdown",
"id": "6c881882",
"metadata": {},
"source": [
"Now suppose your project requires costs that the default `qs.TEA` does not add automatically:\n",
"\n",
"1. **Indirect capital** (engineering, sitework, permitting), estimated at 30% of the installed equipment cost.\n",
"2. A **contingency** of 10% on the direct cost.\n",
"3. An annual **property tax** of 1% of the fixed capital investment."
]
},
{
"cell_type": "code",
"execution_count": 17,
"id": "10d4c1b2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.734994Z",
"iopub.status.busy": "2026-05-30T19:09:26.734994Z",
"iopub.status.idle": "2026-05-30T19:09:26.739928Z",
"shell.execute_reply": "2026-05-30T19:09:26.739928Z"
}
},
"outputs": [],
"source": [
"class CustomTEA(qs.TEA):\n",
" indirect_frac = 0.30 # engineering, sitework, permitting (fraction of installed cost)\n",
" contingency = 0.10 # contingency on the direct cost\n",
" property_tax = 0.01 # annual, as a fraction of FCI\n",
"\n",
" # capital build-up: installed cost -> DPI -> TDC -> FCI\n",
" def _DPI(self, installed_equipment_cost):\n",
" return installed_equipment_cost*(1 + self.indirect_frac)\n",
"\n",
" def _TDC(self, DPI):\n",
" return DPI*(1 + self.contingency)\n",
"\n",
" # _FCI defaults to TDC (no change needed)\n",
"\n",
" # add a property tax on top of qs.TEA's maintenance + labor + add_OPEX\n",
" def _FOC(self, FCI):\n",
" return super()._FOC(FCI) + self.property_tax*FCI\n",
"\n",
" # you can override other things too, e.g. a fixed annual cost for monitoring/lab work\n",
" @property\n",
" def VOC(self):\n",
" return super().VOC + 5e4"
]
},
{
"cell_type": "markdown",
"id": "616c2268",
"metadata": {},
"source": [
"Now we are good to try it out!"
]
},
{
"cell_type": "code",
"execution_count": 18,
"id": "bf656ad4",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.741960Z",
"iopub.status.busy": "2026-05-30T19:09:26.741960Z",
"iopub.status.idle": "2026-05-30T19:09:26.747143Z",
"shell.execute_reply": "2026-05-30T19:09:26.746135Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"CustomTEA: aer_sys\n",
"NPV : -15,885,912 USD at 5.0% discount rate\n"
]
}
],
"source": [
"tea2 = CustomTEA(aer_sys, discount_rate=0.05, lifetime=20,\n",
" annual_labor=4e5, annual_maintenance=0.02)\n",
"tea2.show()"
]
},
{
"cell_type": "markdown",
"id": "s2-capex-md",
"metadata": {},
"source": [
"Because `CustomTEA` overrides `_DPI` and `_TDC`, `DPI` and `TDC` now no longer equal `installed_equipment_cost`. Compared with the flat chain in Section 1.2, each markup now compounds, and `CAPEX` (= TCI) carries them all the way through:"
]
},
{
"cell_type": "code",
"execution_count": 19,
"id": "s2-capex-code",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.747721Z",
"iopub.status.busy": "2026-05-30T19:09:26.747721Z",
"iopub.status.idle": "2026-05-30T19:09:26.752479Z",
"shell.execute_reply": "2026-05-30T19:09:26.752479Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"purchase_cost 5,000,000 USD\n",
"installed_equipment_cost 5,000,000 USD\n",
"DPI 6,500,000 USD\n",
"TDC 7,150,000 USD\n",
"FCI 7,150,000 USD\n",
"TCI 7,150,000 USD\n",
"CAPEX 7,150,000 USD\n"
]
}
],
"source": [
"for attr in ('purchase_cost', 'installed_equipment_cost', 'DPI', 'TDC', 'FCI', 'TCI', 'CAPEX'):\n",
" print(f'{attr:24} {getattr(tea2, attr):>13,.0f} USD')"
]
},
{
"cell_type": "markdown",
"id": "s2-effect-md",
"metadata": {},
"source": [
"Stepping back, here is what the whole subclass does to the bottom line. Against the default `qs.TEA` on the same plant (same discount rate, lifetime, labor, and maintenance), `CustomTEA` increases the **capital** cost through the indirect and contingency markups, increases the **FOC** through the property tax (and through maintenance, which now rides on the larger FCI), and increases the **VOC** through the monitoring/lab consumables. Every override pushes the NPV down (or more negative):"
]
},
{
"cell_type": "code",
"execution_count": 20,
"id": "54f86a89",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.752479Z",
"iopub.status.busy": "2026-05-30T19:09:26.752479Z",
"iopub.status.idle": "2026-05-30T19:09:26.759653Z",
"shell.execute_reply": "2026-05-30T19:09:26.759653Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
" default TEA CustomTEA\n",
"CAPEX 5,000,000 7,150,000\n",
"FCI 5,000,000 7,150,000\n",
"FOC 512,636 627,136\n",
"VOC 23,856 73,856\n",
"AOC 536,492 700,992\n",
"NPV -11,685,878 -15,885,912\n"
]
}
],
"source": [
"# How CustomTEA's overrides change the bottom line, vs the default qs.TEA on the same plant:\n",
"base = qs.TEA(aer_sys, discount_rate=0.05, lifetime=20, annual_labor=4e5, annual_maintenance=0.02)\n",
"print(f'{\"\":12}{\"default TEA\":>15}{\"CustomTEA\":>15}')\n",
"for attr in ('CAPEX', 'FCI', 'FOC', 'VOC', 'AOC', 'NPV'):\n",
" print(f'{attr:12}{getattr(base, attr):>15,.0f}{getattr(tea2, attr):>15,.0f}')"
]
},
{
"cell_type": "markdown",
"id": "teas3md1",
"metadata": {},
"source": [
"## 3. Cost indices \n",
"\n",
"Costs are tied to the year of the data they come from: equipment prices, chemical prices, labor rates, and general price levels all drift over time. To compare or combine costs from different years, each category is rescaled to a common year using its own **price index**. `qsdsan.utils.tea_indices` bundles several of these (equipment, chemicals, labor, and inflation; see below).\n",
"\n",
"The one `qsdsan` exposes as a settable global is the **Chemical Engineering Plant Cost Index (CEPCI)**, used to scale equipment purchase costs. It is available as `qs.CEPCI` (a live view of BioSTEAM's `CE`), with a table of published values by year in `qs.CEPCI_by_year`. We already used it when costing a custom unit in [5. SanUnit (advanced)](https://qsdsan.readthedocs.io/en/latest/tutorials/5_SanUnit_advanced.html): the `@cost` decorator anchors an equipment correlation to a reference-year index (`CE=522`) and scales it to the current `qs.CEPCI` by the factor $CEPCI_{new}/CEPCI$."
]
},
{
"cell_type": "code",
"execution_count": 21,
"id": "teas3code1",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.759653Z",
"iopub.status.busy": "2026-05-30T19:09:26.759653Z",
"iopub.status.idle": "2026-05-30T19:09:26.765665Z",
"shell.execute_reply": "2026-05-30T19:09:26.765665Z"
},
"scrolled": true
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"current CEPCI: 567.5\n",
"CEPCI now: 797.9\n",
"tea_2023 uses CEPCI: 797.9\n"
]
}
],
"source": [
"# the index qsdsan currently uses to scale equipment costs\n",
"print('current CEPCI:', qs.CEPCI)\n",
"\n",
"# look up a published value and set the cost year globally (affects later cost calcs)\n",
"qs.CEPCI = qs.CEPCI_by_year[2023] # report costs in 2023 dollars\n",
"print('CEPCI now:', qs.CEPCI)\n",
"\n",
"# or set it for a single analysis by passing `CEPCI` to the TEA\n",
"tea_2023 = qs.TEA(system=aer_sys, discount_rate=0.05, lifetime=20,\n",
" CEPCI=qs.CEPCI_by_year[2023])\n",
"print('tea_2023 uses CEPCI:', tea_2023.CEPCI)"
]
},
{
"cell_type": "markdown",
"id": "teas3md2",
"metadata": {},
"source": [
"`CEPCI` is one of several economic indices bundled in `qsdsan.utils.tea_indices`, used to adjust different cost categories to a target year:\n",
"\n",
"- `CEPCI_by_year` — equipment (Chemical Engineering Plant Cost Index)\n",
"- `ChemPPI_by_year` — chemical prices (chemical Producer Price Index)\n",
"- `labor_by_year` — labor wages\n",
"- `PCEPI_by_year` — general inflation (Personal Consumption Expenditures Price Index)\n",
"\n",
"You can rescale any cost with the index that matches its category (a chemical price with `ChemPPI_by_year`, a wage with `labor_by_year`, and so on), the same way `CEPCI` rescales equipment. For example, to restate a chemical price quoted in one year in another year's dollars, scale it by the ratio of the chemical PPI in those two years:"
]
},
{
"cell_type": "code",
"execution_count": 22,
"id": "1d9eb582",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.765665Z",
"iopub.status.busy": "2026-05-30T19:09:26.765665Z",
"iopub.status.idle": "2026-05-30T19:09:26.772657Z",
"shell.execute_reply": "2026-05-30T19:09:26.772657Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"NaHCO3: 0.90 USD/kg (2015) -> 0.982 USD/kg (2020)\n"
]
}
],
"source": [
"# Restate a chemical price in another year's dollars with the chemical PPI\n",
"# (e.g., the alkalinity the anaerobic plant buys, quoted earlier at 0.90 USD/kg).\n",
"from qsdsan.utils import tea_indices\n",
"ChemPPI = tea_indices['ChemPPI_by_year']\n",
"from_year, to_year = 2015, 2020\n",
"price = 0.90 # USD/kg NaHCO3, quoted in 2015 dollars\n",
"price_to = price * ChemPPI[to_year] / ChemPPI[from_year] # new/old ratio, as CEPCI does for equipment\n",
"print(f'NaHCO3: {price:.2f} USD/kg ({from_year}) -> {price_to:.3f} USD/kg ({to_year})')"
]
},
{
"cell_type": "markdown",
"id": "dc1935be",
"metadata": {},
"source": [
"Unlike `CEPCI`, these other indices are not exposed as a settable global like `qs.CEPCI`: a model mixes many kinds of cost and there is no single \"current\" index that fits them all, so they are provided as plain lookup tables for you to apply where appropriate. Each maps a year to its index value:"
]
},
{
"cell_type": "code",
"execution_count": 23,
"id": "teas3code2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-05-30T19:09:26.772657Z",
"iopub.status.busy": "2026-05-30T19:09:26.772657Z",
"iopub.status.idle": "2026-05-30T19:09:26.778377Z",
"shell.execute_reply": "2026-05-30T19:09:26.778377Z"
},
"scrolled": true
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"available indices: ['CEPCI_by_year', 'ChemPPI_by_year', 'labor_by_year', 'PCEPI_by_year']\n",
"chemical PPI (2020): 289.0\n",
"labor index (2023): 29.77\n"
]
}
],
"source": [
"from qsdsan.utils import tea_indices\n",
"print('available indices:', list(tea_indices))\n",
"print('chemical PPI (2020):', tea_indices['ChemPPI_by_year'][2020])\n",
"print('labor index (2023):', tea_indices['labor_by_year'][2023])"
]
},
{
"cell_type": "markdown",
"id": "nav-footer-7_tea",
"metadata": {},
"source": [
"\n",
"\n",
"---\n",
"\n",
"↑ Back to top\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.7"
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}