Techno-Economic Analysis (TEA)

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:

    • Set up a TEA on a System

    • Configure cost and financing parameters

    • Read financial metrics: NPV, equivalent annual cost, and break-even price

  • Prerequisites: 6. System

  • Covered topics:

      1. Using the TEA class

      1. Developing your own TEA subclass

      1. Cost indices

Companion video. A walkthrough of this tutorial is available on YouTube, presented by Hannah Lohman. Recorded against QSDsan v1.2.5. 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. Using the TEA class

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.

You can use qs.TEA directly (this section) or subclass it to add your own cost items (Section 2).

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.

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

  • an aerobic activated sludge process: dependable, but it spends electricity on aeration and produces a lot of sludge; and

  • an anaerobic process: it recovers energy as biogas, but it must be heated and dosed with a little alkalinity.

We build a realistic influent and let each plant actually react, which revisits the earlier tutorials: defining Components 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.

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.

[2]:
from qsdsan import SanUnit, WasteStream, Component, Components
from qsdsan.utils import get_digestion_rxns, compute_stream_COD

# A small set of real (formula-bearing) components so the reactions below can balance:
# water, the gases, a representative wastewater organic, and biomass.
def make_cmp(ID, formula=None, search_ID=None, phase='l', size='Soluble',
             deg='Undegradable', org=False):
    return Component(ID, formula=formula, search_ID=search_ID, phase=phase,
                     particle_size=size, degradability=deg, organic=org)

H2O = make_cmp('H2O', search_ID='H2O')
O2  = make_cmp('O2',  search_ID='O2',  phase='g', size='Dissolved gas')
CO2 = make_cmp('CO2', search_ID='CO2', phase='g', size='Dissolved gas')
NH3 = make_cmp('NH3', search_ID='NH3', phase='g', size='Dissolved gas')
CH4 = make_cmp('CH4', search_ID='CH4', phase='g', size='Dissolved gas', deg='Readily', org=True)
NaHCO3 = make_cmp('NaHCO3', search_ID='NaHCO3')
Substrate = make_cmp('Substrate', formula='C10H19O3N', deg='Readily', org=True)  # representative WW organic
Biomass   = make_cmp('Biomass', formula='C5H7O2N', phase='s', size='Particulate', deg='Slowly', org=True)

cmps = Components([H2O, O2, CO2, NH3, CH4, NaHCO3, Substrate, Biomass])
for c in (NaHCO3, Substrate, Biomass):
    c.copy_models_from(H2O, ('V', 'sigma', 'epsilon', 'kappa', 'Cn', 'mu'))
cmps.compile(ignore_inaccurate_molar_weight=True)
qs.set_thermo(cmps)

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.

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.

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.

[3]:
class TreatmentPlant(SanUnit, isabstract=True):
    """Base plant: carries the installed and sludge disposal costs; subclasses add reactions and operating costs."""
    plant_capital = 5e6          # USD installed (M&E Ch. 4, small-plant order of magnitude)
    sludge_disposal_cost = 0.10  # USD/kg dry solids (M&E Ch. 4)
    # 'Plant' is already an installed cost, so its bare-module factor is 1; declaring it here
    # avoids BioSTEAM's "no defined bare-module factor" warning (see the SanUnit tutorial).
    _F_BM_default = {'Plant': 1.}

    def _cost(self):
        self.baseline_purchase_costs['Plant'] = self.plant_capital
[4]:
class AerobicPlant(TreatmentPlant):
    """Activated sludge: grows biomass and oxidizes the rest (aeration O2 = COD oxidized)."""
    _N_ins = 1    # wastewater
    _N_outs = 2   # treated effluent, waste sludge
    X_growth = 0.40   # fraction of biodegradable COD converted to biomass
    X_oxid = 0.95     # fraction of the remaining substrate oxidized to CO2
    O2_per_kWh = 1.2  # aeration efficiency, kg O2 per kWh (M&E)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.growth_rxns = get_digestion_rxns(self.components, 0., self.X_growth, 'Biomass', 1.)
        self._mixed = WasteStream(f'{self.ID}_mixed')   # react here, then split to the outlets

    def _run(self):
        eff, sludge = self.outs
        m = self._mixed; m.copy_like(self.ins[0])
        self.growth_rxns(m.mol)                                      # substrate -> biomass
        oxidized = m.imass['Substrate']*self.X_oxid
        self._O2_demand = oxidized*self.components.Substrate.i_COD*24  # kg O2/d == COD oxidized
        m.imass['Substrate'] -= oxidized                            # oxidized COD leaves as CO2
        sludge.empty(); sludge.phase = 's'; sludge.imass['Biomass'] = m.imass['Biomass']
        m.imass['Biomass'] = 0
        eff.copy_like(m)

    def _cost(self):
        super()._cost()
        self.power_utility(self._O2_demand/self.O2_per_kWh/24)       # kW for aeration
        self.add_OPEX = {'Sludge disposal': self.outs[1].F_mass*self.sludge_disposal_cost}
[5]:
class AnaerobicPlant(TreatmentPlant):
    """Anaerobic digestion: recovers biogas, but needs heating (a utility) and alkalinity."""
    _N_ins = 2    # wastewater, sodium bicarbonate
    _N_outs = 3   # treated effluent, waste sludge, biogas
    X_biogas = 0.86   # fraction of biodegradable COD converted to biogas
    X_growth = 0.05   # fraction converted to biomass
    CH4_LHV = 50000.      # kJ/kg methane (lower heating value)
    energy_price = 5/1e6  # USD/kJ ($5 per 10^6 kJ, M&E)
    T_op = 273.15 + 35    # K, operating temperature (35 C)
    HX_eff = 0.80         # heat recovery efficiency
    NaHCO3_dose = 0.10    # kg/m3 alkalinity

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.rxns = get_digestion_rxns(self.components, self.X_biogas, self.X_growth, 'Biomass', 1.)
        self._mixed = WasteStream(f'{self.ID}_mixed')

    def _run(self):
        ww, chem = self.ins
        eff, sludge, biogas = self.outs
        chem.empty(); chem.imass['NaHCO3'] = self.NaHCO3_dose*ww.F_vol   # kg/hr (dose * m3/hr)
        m = self._mixed; m.copy_like(ww)
        self.rxns(m.mol)                                            # substrate -> biogas + biomass
        biogas.empty(); biogas.phase = 'g'
        biogas.imass['CH4'] = m.imass['CH4']; biogas.imass['CO2'] = m.imass['CO2']
        # value the biogas by its methane energy only (the CO2 in it is inert)
        biogas.price = (biogas.imass['CH4']*self.CH4_LHV*self.energy_price)/biogas.F_mass \
                       if biogas.F_mass else 0.
        sludge.empty(); sludge.phase = 's'; sludge.imass['Biomass'] = m.imass['Biomass']
        m.imass['CH4'] = m.imass['CO2'] = m.imass['Biomass'] = m.imass['NH3'] = 0
        eff.copy_like(m)

    def _design(self):
        # Heating the digester is an energy demand that scales with flow, so it is a
        # utility (like the aerobic plant's aeration), not a fixed operating cost.
        inf = self.ins[0]
        duty = inf.F_mass * inf.Cp * (self.T_op - inf.T)   # kJ/hr of sensible heat
        if duty > 0:
            self.add_heat_utility(duty, inf.T, T_out=self.T_op, heat_transfer_efficiency=self.HX_eff)

    def _cost(self):
        super()._cost()
        self.add_OPEX = {'Sludge disposal': self.outs[1].F_mass*self.sludge_disposal_cost}
[6]:
# Build a realistic municipal influent (COD ~430 mg/L, 20 C) and a system for each plant.
def make_influent(ID, COD=430.):
    ww = WasteStream(ID, T=273.15+20); ww.ivol['H2O'] = 4000/24         # 4,000 m3/d at 20°C
    ww.imass['Substrate'] = (COD/1000 * 4000/24)/Substrate.i_COD        # set the COD
    return ww

qs.main_flowsheet.set_flowsheet('tea_compare')
aer = AerobicPlant('aer', ins=make_influent('ww_aer'), outs=('aer_effluent', 'aer_sludge'))
aer_sys = qs.System('aer_sys', path=(aer,))

NaHCO3_feed = WasteStream('NaHCO3_feed', price=0.90)   # USD/kg (Ch. 10)
ana = AnaerobicPlant('ana', ins=(make_influent('ww_ana'), NaHCO3_feed),
                     outs=('ana_effluent', 'ana_sludge', 'biogas'))
ana_sys = qs.System('ana_sys', path=(ana,))

qs.PowerUtility.price = 0.08   # USD/kWh electricity (M&E Ch. 4)
aer_sys.simulate(); ana_sys.simulate()
aer_sys.diagram()   # default format; pass format='html' for an interactive diagram
../_images/tutorials_7_TEA_11_0.svg
[7]:
# Each plant reacts the influent; the design and costs come from the reacted streams.
print('aerobic effluent COD:  ', round(compute_stream_COD(aer.outs[0], 'mg/L'), 1), 'mg/L')
print('anaerobic effluent COD:', round(compute_stream_COD(ana.outs[0], 'mg/L'), 1), 'mg/L')
print(aer.results())
print(ana.results())
aerobic effluent COD:   12.9 mg/L
anaerobic effluent COD: 38.7 mg/L
Aerobic plant               Units   aer
Electricity         Power      kW    34
                    Cost   USD/hr  2.72
Purchase cost       Plant     USD 5e+06
Total purchase cost           USD 5e+06
Utility cost               USD/hr  2.72
Sludge disposal            USD/hr  1.44
Anaerobic plant              Units      ana
Low pressure steam  Duty     kJ/hr 1.31e+07
                    Flow   kmol/hr      337
                    Cost    USD/hr     80.2
Purchase cost       Plant      USD    5e+06
Total purchase cost            USD    5e+06
Utility cost                USD/hr     80.2
Sludge disposal             USD/hr     0.18

1.1. What costs does qs.TEA include?

qs.TEA sorts every cash item into a few categories. Our two plants populate different ones:

Cost

Aerobic plant

Anaerobic plant

Capital

installed plant cost

installed plant cost

Utility (VOC)

aeration electricity

heating steam

Material (VOC)

none

alkalinity NaHCO₃

Unit add_OPEX

sludge disposal

sludge disposal

Maintenance + labor (FOC)

not set (0)

not set (0)

Sales

none

biogas (methane energy)

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:

[8]:
tea_aer = qs.TEA(aer_sys, discount_rate=0.05, lifetime=20)
tea_ana = qs.TEA(ana_sys, discount_rate=0.05, lifetime=20)
tea_aer.show()
tea_ana.show()
TEA: aer_sys
NPV  : -5,454,773 USD at 5.0% discount rate
TEA: ana_sys
NPV  : -14,995,309 USD at 5.0% discount rate

1.2. How the cost components add up

Capital Expenditure (CAPEX). By default qs.TEA collapses the capital hierarchy, so every level equals the installed equipment cost:

installed_equipment_cost = DPI = TDC = FCI = TCI

(no indirect markups until you subclass, as in Section 2).

Operating Expenditure (OPEX). The annual operating cost (AOC) is the sum of fixed and variable operating costs (FOC and VOC):

\[FOC = FCI \times annual\_maintenance + annual\_labor + add\_OPEX\]
\[VOC = material\ cost + utility\ cost\]
\[AOC = FOC + VOC\]

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.

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

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:

  • a genuinely fixed cost (a maintenance contract, salaried staff): use annual_maintenance (a fraction of FCI) or annual_labor;

  • 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);

  • add_OPEX is the convenient catch-all in between.

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.

Laying the two plants side by side shows how differently their costs are structured:

[9]:
def breakdown(name, tea):
    print(f'{name:>10}: CAPEX {tea.CAPEX:>11,.0f} | material {tea.material_cost:>11,.0f} | '
          f'utility {tea.utility_cost:>11,.0f} | add_OPEX {tea.unit_add_OPEX:>9,.0f} | '
          f'sales {tea.sales:>9,.0f} | AOC {tea.AOC:>11,.0f}')

print('all values in USD (CAPEX) or USD/yr')
breakdown('aerobic', tea_aer)
breakdown('anaerobic', tea_ana)
all values in USD (CAPEX) or USD/yr
   aerobic: CAPEX   5,000,000 | material           0 | utility      23,856 | add_OPEX    12,636 | sales         0 | AOC      36,492
 anaerobic: CAPEX   5,000,000 | material     131,403 | utility     702,902 | add_OPEX     1,579 | sales    33,835 | AOC     835,884

1.3. Parameters and their defaults

The most commonly adjusted qs.TEA parameters are below; run ?qs.TEA or see the API docs for the complete list.

Parameter

Default

Meaning

discount_rate

0.05

Discount rate for the cash flow analysis (equals IRR when NPV = 0).

income_tax

0.

Combined tax rate applied to net earnings.

lifetime

10

Operating lifetime, in years (the depreciation schedule must fit within it; see 1.5).

start_year

current year

Calendar year operation begins.

uptime_ratio

1.

Fraction of the year the system operates.

annual_maintenance

0.

Maintenance cost as a fraction of FCI.

annual_labor

0.

Labor cost, USD/yr.

system_add_OPEX

{}

Extra system-level operating cost on top of each unit’s add_OPEX.

depreciation

'SL'

Depreciation schedule: 'SL' (default), 'DDB', 'SYD', 'MACRS5', 'MACRS7', …; see 1.5.

CEPCI

None

Cost index for equipment scaling; leave as is, or pass a year’s value (Section 3).

CAPEX, lang_factor

0., None

Alternative ways to set the installed cost (see Section 2).

Changing a parameter and re-reading a metric takes effect immediately:

[10]:
for r in (0.03, 0.05, 0.10):
    tea_aer.discount_rate = r
    print(f'discount_rate = {r:.0%}  ->  aerobic NPV = {tea_aer.NPV:,.0f} USD')
tea_aer.discount_rate = 0.05   # restore
discount_rate = 3%  ->  aerobic NPV = -5,542,911 USD
discount_rate = 5%  ->  aerobic NPV = -5,454,773 USD
discount_rate = 10%  ->  aerobic NPV = -5,310,678 USD

1.4. How the financial metrics are computed

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

[11]:
tea_ana.get_cashflow_table().iloc[:6, :5]   # first few years and columns; you can drop the slice for the full table
[11]:
Depreciable capital [MM$] Fixed capital investment [MM$] Working capital [MM$] Depreciation [MM$] Loan [MM$]
2024 0 0 0 0 0
2025 5 5 0 0 0
2026 0 0 0 0.25 0
2027 0 0 0 0.25 0
2028 0 0 0 0.25 0
2029 0 0 0 0.25 0

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:

[12]:
Q_annual = 4000 * 365   # m3/yr
for name, tea, U in (('aerobic', tea_aer, aer), ('anaerobic', tea_ana, ana)):
    fee = -tea.solve_price(U.ins[0]) * 1000   # USD/m3 (negative price = paid to receive)
    print(f'{name:>10}: NPV {tea.NPV:>14,.0f} USD | EAC {tea.EAC:>12,.0f} USD/yr | '
          f'break-even fee {fee:5.2f} USD/m3')
   aerobic: NPV     -5,454,773 USD | EAC      437,705 USD/yr | break-even fee  0.30 USD/m3
 anaerobic: NPV    -14,995,309 USD | EAC    1,237,097 USD/yr | break-even fee  0.83 USD/m3

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.

This balance flips for strong (for example, industrial) wastewater. Let’s sweep the influent COD and see where anaerobic overtakes aerobic:

[13]:
import pandas as pd
rows = []
for COD in (430, 1000, 2000, 4000, 8000):
    a = AerobicPlant(f'a{COD}', ins=make_influent(f'wa{COD}', COD), outs=('', ''))
    n = AnaerobicPlant(f'n{COD}', ins=(make_influent(f'wn{COD}', COD),
                       WasteStream(f'c{COD}', price=0.90)), outs=('', '', ''))
    a_sys = qs.System(f'as{COD}', path=(a,)); n_sys = qs.System(f'ns{COD}', path=(n,))
    a_sys.simulate(); n_sys.simulate()
    ta = qs.TEA(a_sys, discount_rate=0.05, lifetime=20)
    tn = qs.TEA(n_sys, discount_rate=0.05, lifetime=20)
    rows.append((COD, round(ta.NPV), round(tn.NPV), 'aerobic' if ta.NPV > tn.NPV else 'anaerobic'))

pd.DataFrame(rows, columns=['COD (mg/L)', 'aerobic NPV', 'anaerobic NPV', 'winner'])
[13]:
COD (mg/L) aerobic NPV anaerobic NPV winner
0 430 -5454773 -14995309 aerobic
1 1000 -6057611 -14462724 aerobic
2 2000 -7115223 -13528365 aerobic
3 4000 -9230445 -11659647 aerobic
4 8000 -13460890 -7922212 anaerobic

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.

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.

Annualized capital cost. qs.TEA also exposes two related annual capital metrics:

  • 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.

  • 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.

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.

[14]:
# Base case: the plant equipment is assumed to last the full 20-year project, so the two agree.
print('equipment lasts the project (no replacement):')
print(f'  annualized_equipment_cost : {tea_aer.annualized_equipment_cost:,.0f} USD/yr')
print(f'  annualized_CAPEX          : {tea_aer.annualized_CAPEX:,.0f} USD/yr')

# Now give the equipment a 15-year life, so it is replaced once inside the 20-year project.
# The cash flow charges that replacement, but annualized_equipment_cost still annualizes over
# the 15-year equipment life (implicitly salvaging the value left at year 20):
aer.lifetime = 15   # the unit's equipment lifetime (`lifetime` aliases `equipment_lifetime`)
aer_sys.simulate()
tea_15 = qs.TEA(aer_sys, discount_rate=0.05, lifetime=20)
print('\n15-year equipment life (replaced once, at year 15):')
print(f'  annualized_equipment_cost : {tea_15.annualized_equipment_cost:,.0f} USD/yr  (optimistic)')
print(f'  annualized_CAPEX          : {tea_15.annualized_CAPEX:,.0f} USD/yr')
aer.lifetime = {}; aer_sys.simulate()   # restore the base case for later sections

# You can export the full system, design, and TEA results to Excel:
# aer_sys.save_report(file='aer_sys.xlsx')
equipment lasts the project (no replacement):
  annualized_equipment_cost : 401,213 USD/yr
  annualized_CAPEX          : 401,213 USD/yr

15-year equipment life (replaced once, at year 15):
  annualized_equipment_cost : 481,711 USD/yr  (optimistic)
  annualized_CAPEX          : 585,013 USD/yr

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 for the full workflow.

1.5. Depreciation and taxes

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.

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.

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:

[15]:
for dep in ('SL', 'MACRS5', 'MACRS7'):
    tea = qs.TEA(aer_sys, discount_rate=0.05, lifetime=20, income_tax=0., depreciation=dep)
    print(f'{dep:7}: NPV = {tea.NPV:,.0f} USD')   # identical: with no tax, depreciation is irrelevant
SL     : NPV = -5,454,773 USD
MACRS5 : NPV = -5,454,773 USD
MACRS7 : NPV = -5,454,773 USD

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.

[16]:
aer.ins[0].price = -0.0006   # USD/kg influent: a treatment fee of ~0.6 USD/m3 (negative = paid to treat)
aer_sys.simulate()
for dep in ('SL', 'MACRS5', 'MACRS7'):
    tea = qs.TEA(aer_sys, discount_rate=0.05, lifetime=20, income_tax=0.21, depreciation=dep)
    print(f'{dep:7}: NPV = {tea.NPV:,.0f} USD')
aer.ins[0].price = 0.   # restore the base case
SL     : NPV = 3,905,892 USD
MACRS5 : NPV = 4,140,476 USD
MACRS7 : NPV = 4,127,112 USD

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.

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.

2. Developing your own TEA subclass

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.

The standard chemical-engineering build-up (Seider et al., Product and Process Design Principles, Ch. 16) goes:

Level

qs.TEA attribute

What it adds on top of the previous level

Purchased equipment cost

purchase_cost

the vendor (free on board, f.o.b.) price of the equipment

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)

Direct permanent investment

DPI

site preparation and service facilities

Total depreciable capital

TDC

contingency and contractor fee (~18% of DPI)

Fixed capital investment

FCI

nondepreciable, one-time items: land, royalties, startup

Total capital investment

TCI, returned by CAPEX

working capital

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.

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.

  • _DPI direct permanent investment, calculated using installed_equipment_cost;

  • _TDC total depreciable capital, calculated using _DPI;

  • _FCI fixed capital investment, calculated using _TDC;

  • _FOC fixed operating cost, calculated using _FCI.

Now suppose your project requires costs that the default qs.TEA does not add automatically:

  1. Indirect capital (engineering, sitework, permitting), estimated at 30% of the installed equipment cost.

  2. A contingency of 10% on the direct cost.

  3. An annual property tax of 1% of the fixed capital investment.

[17]:
class CustomTEA(qs.TEA):
    indirect_frac = 0.30   # engineering, sitework, permitting (fraction of installed cost)
    contingency = 0.10     # contingency on the direct cost
    property_tax = 0.01    # annual, as a fraction of FCI

    # capital build-up: installed cost -> DPI -> TDC -> FCI
    def _DPI(self, installed_equipment_cost):
        return installed_equipment_cost*(1 + self.indirect_frac)

    def _TDC(self, DPI):
        return DPI*(1 + self.contingency)

    # _FCI defaults to TDC (no change needed)

    # add a property tax on top of qs.TEA's maintenance + labor + add_OPEX
    def _FOC(self, FCI):
        return super()._FOC(FCI) + self.property_tax*FCI

    # you can override other things too, e.g. a fixed annual cost for monitoring/lab work
    @property
    def VOC(self):
        return super().VOC + 5e4

Now we are good to try it out!

[18]:
tea2 = CustomTEA(aer_sys, discount_rate=0.05, lifetime=20,
                 annual_labor=4e5, annual_maintenance=0.02)
tea2.show()
CustomTEA: aer_sys
NPV  : -15,885,912 USD at 5.0% discount rate

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:

[19]:
for attr in ('purchase_cost', 'installed_equipment_cost', 'DPI', 'TDC', 'FCI', 'TCI', 'CAPEX'):
    print(f'{attr:24} {getattr(tea2, attr):>13,.0f} USD')
purchase_cost                5,000,000 USD
installed_equipment_cost     5,000,000 USD
DPI                          6,500,000 USD
TDC                          7,150,000 USD
FCI                          7,150,000 USD
TCI                          7,150,000 USD
CAPEX                        7,150,000 USD

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

[20]:
# How CustomTEA's overrides change the bottom line, vs the default qs.TEA on the same plant:
base = qs.TEA(aer_sys, discount_rate=0.05, lifetime=20, annual_labor=4e5, annual_maintenance=0.02)
print(f'{"":12}{"default TEA":>15}{"CustomTEA":>15}')
for attr in ('CAPEX', 'FCI', 'FOC', 'VOC', 'AOC', 'NPV'):
    print(f'{attr:12}{getattr(base, attr):>15,.0f}{getattr(tea2, attr):>15,.0f}')
                default TEA      CustomTEA
CAPEX             5,000,000      7,150,000
FCI               5,000,000      7,150,000
FOC                 512,636        627,136
VOC                  23,856         73,856
AOC                 536,492        700,992
NPV             -11,685,878    -15,885,912

3. Cost indices

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

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): 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\).

[21]:
# the index qsdsan currently uses to scale equipment costs
print('current CEPCI:', qs.CEPCI)

# look up a published value and set the cost year globally (affects later cost calcs)
qs.CEPCI = qs.CEPCI_by_year[2023]   # report costs in 2023 dollars
print('CEPCI now:', qs.CEPCI)

# or set it for a single analysis by passing `CEPCI` to the TEA
tea_2023 = qs.TEA(system=aer_sys, discount_rate=0.05, lifetime=20,
                  CEPCI=qs.CEPCI_by_year[2023])
print('tea_2023 uses CEPCI:', tea_2023.CEPCI)
current CEPCI: 567.5
CEPCI now: 797.9
tea_2023 uses CEPCI: 797.9

CEPCI is one of several economic indices bundled in qsdsan.utils.tea_indices, used to adjust different cost categories to a target year:

  • CEPCI_by_year — equipment (Chemical Engineering Plant Cost Index)

  • ChemPPI_by_year — chemical prices (chemical Producer Price Index)

  • labor_by_year — labor wages

  • PCEPI_by_year — general inflation (Personal Consumption Expenditures Price Index)

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:

[22]:
# Restate a chemical price in another year's dollars with the chemical PPI
# (e.g., the alkalinity the anaerobic plant buys, quoted earlier at 0.90 USD/kg).
from qsdsan.utils import tea_indices
ChemPPI = tea_indices['ChemPPI_by_year']
from_year, to_year = 2015, 2020
price = 0.90                                               # USD/kg NaHCO3, quoted in 2015 dollars
price_to = price * ChemPPI[to_year] / ChemPPI[from_year]   # new/old ratio, as CEPCI does for equipment
print(f'NaHCO3: {price:.2f} USD/kg ({from_year}) -> {price_to:.3f} USD/kg ({to_year})')
NaHCO3: 0.90 USD/kg (2015) -> 0.982 USD/kg (2020)

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:

[23]:
from qsdsan.utils import tea_indices
print('available indices:', list(tea_indices))
print('chemical PPI (2020):', tea_indices['ChemPPI_by_year'][2020])
print('labor index (2023):', tea_indices['labor_by_year'][2023])
available indices: ['CEPCI_by_year', 'ChemPPI_by_year', 'labor_by_year', 'PCEPI_by_year']
chemical PPI (2020): 289.0
labor index (2023): 29.77

↑ Back to top