Life Cycle Assessment (LCA)

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

Launch Binder

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

  • Prepared by:

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

    • Define ImpactIndicator and ImpactItem instances

    • Run an LCA on a System

    • Interpret environmental footprints by category and contributor, and compare systems

  • Prerequisites: 6. System, 7. TEA

  • Covered topics:

      1. ImpactIndicator

      1. ImpactItem and StreamImpactItem

      1. Build the system for LCA

      1. Construction, Transportation, and other activities

      1. LCA

Companion video. A walkthrough of this tutorial is available on YouTube, presented by Tori Morgan. Recorded against QSDsan v1.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. ImpactIndicator

LCA is a bit more complicated than TEA since there are multiple indicators we can choose from and we might want to see the life cycle impact assessment (LCIA) results for multiple indicators. Therefore, qsdsan implements multiple new classes for LCA, and the one to start with is the ImpactIndicator class.

When initializing a new ImpactIndicator instance, ID and unit are the most important attributes, alias is for convenience, and method, category, and description are mostly for record purpose.

[2]:
# Assume we are mostly interested in global warming potential and fossil energy consumption
GWP = qs.ImpactIndicator(ID='GlobalWarming', method='TRACI', category='environmental impact', unit='kg CO2-eq',
                         description='Effect of climate change measured as global warming potential.')
FEC = qs.ImpactIndicator(ID='FossilEnergyConsumption', alias='FEC', unit='MJ')
[3]:
GWP.show()
FEC.show()
ImpactIndicator: GlobalWarming as kg CO2-eq
 Alias      : None
 Method     : TRACI
 Category   : environmental impact
 Description: Effect of climate change ...
ImpactIndicator: FossilEnergyConsumption as MJ
 Alias      : FEC
 Method     : None
 Category   : None
 Description: None
[4]:
# You can set alias for indicators (you can also set it during initiation as in the example above for FEC)
GWP.alias = 'GWP'
GWP.show()
ImpactIndicator: GlobalWarming as kg CO2-eq
 Alias      : GWP
 Method     : TRACI
 Category   : environmental impact
 Description: Effect of climate change ...
[5]:
# You can also retrieve an `ImpactIndicator` through its ID or alias
qs.ImpactIndicator.get_indicator('FEC').show()
ImpactIndicator: FossilEnergyConsumption as MJ
 Alias      : FEC
 Method     : None
 Category   : None
 Description: None
[6]:
# Or get all defined impact indicators
qs.ImpactIndicator.get_all_indicators()
[6]:
{'GlobalWarming': <ImpactIndicator: GlobalWarming>,
 'FossilEnergyConsumption': <ImpactIndicator: FossilEnergyConsumption>}

2. ImpactItem and StreamImpactItem

Once you have impact indicators, you can start adding impact items and specifying the environmental impacts for each functional unit of the item.

2.1. ImpactItem

For example, assume we need some electricity, and that to generate 1 kWh of electricity, 0.25 kg of CO2 is emitted, and it uses 4500 kJ of fossil energy.

[7]:
electricity = qs.ImpactItem('electricity', functional_unit='kWh')
electricity.show()
ImpactItem      : electricity [per kWh]
Price           : None USD
ImpactIndicators:
 None

Note: ImpactItem also has a price attribute that allows you to put in the price for the impact item, but it is mainly used for construction items and will be included in unit CAPEX, if you want to account for the cost of electricity, you should set the power utility usage of the unit and modify qsdsan.PowerUtility.price.

[8]:
# We can add characterization factors (CFs) for the two impact indicators GWP and FEC
electricity.add_indicator(GWP, 0.25)
electricity.show()
ImpactItem      : electricity [per kWh]
Price           : None USD
ImpactIndicators:
                           Characterization factors
GlobalWarming (kg CO2-eq)                      0.25
[9]:
# If you provide unit when adding the CF value, qsdsan will do the unit conversion
electricity.add_indicator(FEC, 4500, 'kJ')
electricity.show()
ImpactItem      : electricity [per kWh]
Price           : None USD
ImpactIndicators:
                              Characterization factors
GlobalWarming (kg CO2-eq)                         0.25
FossilEnergyConsumption (MJ)                       4.5
[10]:
# If you later want to change this value, you can also do it
electricity.CFs['FossilEnergyConsumption'] = 50
electricity.show()
ImpactItem      : electricity [per kWh]
Price           : None USD
ImpactIndicators:
                              Characterization factors
GlobalWarming (kg CO2-eq)                         0.25
FossilEnergyConsumption (MJ)                        50
[11]:
# Similar to `ImpactIndicator`, you can retrieve one `ImpactItem` by its ID,
# or see all defined impact items
qs.ImpactItem.get_item('electricity').show()
qs.ImpactItem.get_all_items()
ImpactItem      : electricity [per kWh]
Price           : None USD
ImpactIndicators:
                              Characterization factors
GlobalWarming (kg CO2-eq)                         0.25
FossilEnergyConsumption (MJ)                        50
[11]:
{'electricity': <ImpactItem: electricity>}

Loading many at once. Real studies define dozens of indicators and items. Rather than creating each by hand, ImpactIndicator.load_from_file(path) and ImpactItem.load_from_file(path) load them in bulk. Both accept either a path to an Excel workbook or a dict of pandas.DataFrame objects (one per sheet). For items, the info sheet lists each item’s ID, functional_unit, and kind, and one sheet per indicator (named by the indicator ID or alias) gives the characterization factors. The small example below loads two construction materials at once:

[12]:
import pandas as pd
# load_from_file takes a path to an Excel workbook or, as here, a dict of DataFrames
# (one per sheet). The 'info' sheet lists each item; it looks like this:
item_data = {
    'info': pd.DataFrame({'ID': ['Steel', 'Concrete'],
                          'functional_unit': ['kg', 'm3'],
                          'kind': ['ImpactItem', 'ImpactItem']}),
    'GWP': pd.DataFrame({'ID': ['Steel', 'Concrete'],
                         'unit': ['kg CO2-eq', 'kg CO2-eq'],
                         'expected': [2.0, 300.0]}),
    'FEC': pd.DataFrame({'ID': ['Steel', 'Concrete'],
                         'unit': ['MJ', 'MJ'],
                         'expected': [25.0, 900.0]}),
}
item_data['info']
[12]:
ID functional_unit kind
0 Steel kg ImpactItem
1 Concrete m3 ImpactItem

There is then one sheet per indicator, named by the indicator’s ID or alias, giving each item’s characterization factor (expected) and its unit:

[13]:
item_data['GWP']
[13]:
ID unit expected
0 Steel kg CO2-eq 2
1 Concrete kg CO2-eq 300

Passing the dict (or an Excel path with the same sheet layout) to load_from_file creates the items:

[14]:
qs.ImpactItem.load_from_file(item_data)
qs.ImpactItem.get_item('Concrete').show()
ImpactItem      : Concrete [per m3]
Price           : None USD
ImpactIndicators:
                              Characterization factors
GlobalWarming (kg CO2-eq)                          300
FossilEnergyConsumption (MJ)                       900

2.2. StreamImpactItem

A special case of impact items are ones related to material inputs and generated wastes/emissions, and it’s more convenient to use StreamImpactItem (a subclass of ImpactItem).

A big perk of using StreamImpactItem is that you can link it to a particular stream, therefore after you simulate the system, quantity of this item will be automatically updated.

[15]:
# For example, let's assume we want to account for the emissions with the wastewater
# note that indicator CFs can be added via a number of a tuple of (quantity, unit)
cmps = qs.Components.load_default()
qs.set_thermo(cmps)
ww = qs.WasteStream.codbased_inf_model(flow_tot=1000)
ww_item = qs.StreamImpactItem(linked_stream=ww, GWP=1, FEC=(100, 'kJ'))
ww_item.show()
StreamImpactItem: ws1_item [per kg]
Linked to       : ws1
Price           : None USD
ImpactIndicators:
                              Characterization factors
GlobalWarming (kg CO2-eq)                            1
FossilEnergyConsumption (MJ)                       0.1
[16]:
# The price of the impact item will be linked to the price of the stream
ww.price = 0.2
ww_item.show()
StreamImpactItem: ws1_item [per kg]
Linked to       : ws1
Price           : 0.2 USD
ImpactIndicators:
                              Characterization factors
GlobalWarming (kg CO2-eq)                            1
FossilEnergyConsumption (MJ)                       0.1
[17]:
# And you would get an error if you tried to set the price of the impact item
# (because it is linked to the stream)
ww_item.price = 0.1
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[17], line 3
      1 # And you would get an error if you tried to set the price of the impact item
      2 # (because it is linked to the stream)
----> 3 ww_item.price = 0.1

AttributeError: property '_price' of 'StreamImpactItem' object has no setter
[18]:
# you should set the price of the stream instead
ww.price = 0.1
ww_item.show()
StreamImpactItem: ws1_item [per kg]
Linked to       : ws1
Price           : 0.1 USD
ImpactIndicators:
                              Characterization factors
GlobalWarming (kg CO2-eq)                            1
FossilEnergyConsumption (MJ)                       0.1
[19]:
# In designing the system, you may need multiple items of the same settings,
# for example you can have many wastewater streams,
# you can use the `copy` method and use `set_as_source` to set
# the original impact item as the source
# (so that if you need to update something, you can just update one impact item)
ww2 = qs.WasteStream.bodbased_inf_model(flow_tot=200)
ww_item2 = ww_item.copy(stream=ww2, set_as_source=True)
ww_item2.show()
StreamImpactItem: ws2_item [per kg]
Linked to       : ws2
Source          : ws1_item
Price           : None USD
ImpactIndicators:
                              Characterization factors
GlobalWarming (kg CO2-eq)                            1
FossilEnergyConsumption (MJ)                       0.1
[20]:
# Updating the CF values of either of the impact items would affect both
ww_item2.CFs['FossilEnergyConsumption'] = 1
ww_item.CFs['FossilEnergyConsumption'] == ww_item2.CFs['FossilEnergyConsumption']
[20]:
True
[21]:
# This works for ImpactItem objects other than StreamImpactItem as well
e2 = electricity.copy(new_ID='copied_electricity', set_as_source=True)
e2.show()
ImpactItem      : copied_electricity [per kWh]
Source          : electricity
Price           : None USD
ImpactIndicators:
                              Characterization factors
GlobalWarming (kg CO2-eq)                         0.25
FossilEnergyConsumption (MJ)                        50

3. Build the system for LCA

Like a TEA, an LCA is linked to a System. We reuse the two wastewater treatment plants from the 7. TEA tutorial, an aerobic activated-sludge plant and an anaerobic plant, packaged for convenience as create_example_treatment_systems. The aerobic plant spends electricity on aeration; the anaerobic plant recovers energy as biogas.

The LCA registries (indicators, items, construction, and transportation) are scoped to the active flowsheet, so we work in a dedicated flowsheet to keep this analysis isolated. Switching the flowsheet swaps those registries, which is why we (re)define the indicators here.

[22]:
# We will directly import the example treatment systems,
# as we have defined them in the TEA tutorial
from qsdsan.utils import create_example_treatment_systems

# A dedicated flowsheet isolates this LCA's registries (see the note below)
qs.main_flowsheet.set_flowsheet('lca_demo')

# Indicators live in the active flowsheet, so define them here
GWP = qs.ImpactIndicator('GlobalWarming', alias='GWP', unit='kg CO2-eq')
FEC = qs.ImpactIndicator('FossilEnergyConsumption', alias='FEC', unit='MJ')

# The aerobic and anaerobic plants from the TEA tutorial (same wastewater, 4,000 m3/d)
aer_sys, ana_sys = create_example_treatment_systems()
aer_sys.simulate(); ana_sys.simulate()
aer, ana = aer_sys.units[0], ana_sys.units[0]
aer_sys.diagram()   # default format; pass format='html' for an interactive diagram
../_images/tutorials_8_LCA_33_0.svg

Flowsheet-scoped registries. ImpactIndicator, ImpactItem, Construction, and Transportation objects all live in the registry of the active flowsheet, not in a global namespace. Switching flowsheets (with qs.main_flowsheet.set_flowsheet(...) or the with qs.Flowsheet(...) context manager) atomically swaps them, so two systems can reuse the same names without clashing. The old clear_lca_registries() helper is deprecated; use a dedicated flowsheet for isolation instead.

[23]:
# A fresh flowsheet starts with empty LCA registries, so names never clash
with qs.Flowsheet('scratch'):
    print('indicators inside "scratch":', list(qs.ImpactIndicator.get_all_indicators()))
# exiting the context restores the previous (lca_demo) flowsheet
print('back in "lca_demo"     :', list(qs.ImpactIndicator.get_all_indicators()))
indicators inside "scratch": []
back in "lca_demo"     : ['GlobalWarming', 'FossilEnergyConsumption']

4. Construction, Transportation, and other activities

With indicators and items in place, we build the life cycle inventory of the two plants. qsdsan groups inventory into four categories: construction, transportation, stream (material inputs and emissions), and other activities (for example, electricity).

4.1. Construction

Construction captures impacts that occur once per lifetime of a piece of equipment or unit. Each Construction links an ImpactItem to a quantity (impact = quantity x CF) and is stored on the unit in SanUnit.construction. We give both plants some concrete and steel:

[24]:
Concrete = qs.ImpactItem('Concrete', functional_unit='m3', GWP=300., FEC=900.)
Steel = qs.ImpactItem('Steel', functional_unit='kg', GWP=2., FEC=25.)

# the anaerobic plant has the larger concrete digester, so give it more material
for u, concrete_m3, steel_kg in ((aer, 300, 30000), (ana, 400, 40000)):
    u.construction = [
        qs.Construction(linked_unit=u, item=Concrete, quantity=concrete_m3,
                        quantity_unit='m3', lifetime=30),
        qs.Construction(linked_unit=u, item=Steel, quantity=steel_kg,
                        quantity_unit='kg', lifetime=30),
    ]
aer.construction[0].show()
Construction : lca_demo_aer_Constr1
Impact item  : Concrete
Lifetime     : 30 yr
Quantity     : 300 m3
Total cost   : None USD
Total impacts:
                              Impacts
GlobalWarming (kg CO2-eq)       9e+04
FossilEnergyConsumption (MJ)  2.7e+05

Construction lifetime. If a Construction item’s lifetime (years) is shorter than the system lifetime, LCA adds a replacement each time it elapses; if left None, the item is assumed to last the whole system lifetime. Realistic lifetimes matter for accurate construction impacts.

Declaring default materials. If a unit class always needs certain construction materials, you can declare them as a class-level _construction_specs tuple instead of building Construction objects in __init__. The materials are looked up lazily (from the active flowsheet) when the LCA is created, so the ImpactItem objects need not exist when the unit is created. Each spec is a dict with item, quantity, quantity_unit, and optionally lifetime and lifetime_unit.

[25]:
class ConcreteReactor(qs.SanUnit):
    _construction_specs = (
        {'item': 'SpecConcrete', 'quantity': 5, 'quantity_unit': 'm3'},
        {'item': 'SpecSteel', 'quantity': 10, 'quantity_unit': 'kg', 'lifetime': 20},
    )
    _N_ins = _N_outs = 1
    def _run(self):
        self.outs[0].copy_like(self.ins[0])
[26]:
# Build the unit in its own flowsheet so the demo stays isolated; the items the
# specs reference must exist in that flowsheet when the LCA is created.
with qs.Flowsheet('specs_demo'):
    GWP_d = qs.ImpactIndicator('GlobalWarming', alias='GWP', unit='kg CO2-eq')
    qs.set_thermo(qs.Components.load_default())
    feed = qs.WasteStream('feed_demo', H2O=1000, units='kg/hr')
    reactor = ConcreteReactor('reactor_demo', ins=feed)
    qs.ImpactItem('SpecConcrete', 'm3', GWP=100.)   # must be loaded before LCA
    qs.ImpactItem('SpecSteel', 'kg', GWP=2.55)
    sys_demo = qs.System('sys_demo', path=(reactor,)); sys_demo.simulate()
    lca_demo_specs = qs.LCA(sys_demo, lifetime=20, simulate_system=False)
    # 5 m3 SpecConcrete x 100 + 10 kg SpecSteel x 2.55 = 525.5
    print(f"resolved construction GWP: {lca_demo_specs.get_construction_impacts()['GlobalWarming']:.2f} kg CO2-eq")
resolved construction GWP: 525.50 kg CO2-eq

If you set unit.construction explicitly for an item that also appears in _construction_specs, the explicit value takes precedence and the spec is skipped for that item. Specs for all other items are still resolved normally.

4.2. Transportation

Transportation also links to an ImpactItem, but computes impacts from a load, a distance, and an interval (how often a trip is made). Here each plant hauls its waste sludge to a landfill 50 km away, once a year:

[27]:
Trucking = qs.ImpactItem('Trucking', functional_unit='kg*km', GWP=1e-4, FEC=2e-3)
for u in (aer, ana):
    annual_sludge = u.outs[1].F_mass * 8760     # kg/yr (the waste sludge outlet)
    u.transportation = [qs.Transportation(
        linked_unit=u, item=Trucking, load_type='mass',
        load=annual_sludge, load_unit='kg', distance=50, distance_unit='km',
        interval=1, interval_unit='yr')]
aer.transportation[0].show()
Transportation: lca_demo_aer_Trans1
Impact item   : Trucking [per trip]
Load          : 126357.5 kg
Distance      : 50 km
Interval      : 8766 hr
Total cost    : None USD
Total impacts :
                              Impacts
GlobalWarming (kg CO2-eq)         632
FossilEnergyConsumption (MJ) 1.26e+04

Note. A Transportation object’s impacts are for a single trip; LCA multiplies by the number of trips over the system lifetime, computed from interval.

4.3. Stream emissions and other activities

Material inputs and emissions are captured with StreamImpactItem objects linked to streams (Section 2.2). We add a direct emission for sludge handling on both plants, and an avoided-impact credit for the anaerobic plant’s biogas (a negative CF, because the recovered methane displaces fossil natural gas):

[28]:
qs.StreamImpactItem(linked_stream=aer.outs[1], GWP=0.2)              # aerobic sludge handling
qs.StreamImpactItem(linked_stream=ana.outs[1], GWP=0.2)             # anaerobic sludge handling
qs.StreamImpactItem(linked_stream=ana.outs[2], GWP=-2.0, FEC=-50.)  # biogas displaces natural gas
StreamImpactItem: biogas_item [per kg]
Linked to       : biogas
Price           : 0.09 USD
ImpactIndicators:
                              Characterization factors
GlobalWarming (kg CO2-eq)                           -2
FossilEnergyConsumption (MJ)                       -50

Impacts not covered by construction, transportation, or streams (here, the aeration electricity) are added directly when creating the LCA. We define an electricity item now and pass each plant’s lifetime electricity use in the next section:

[29]:
electricity = qs.ImpactItem('electricity', functional_unit='kWh', GWP=0.5, FEC=5.93)

5. LCA

Now we run the LCA. Construction, transportation, and stream impacts are read from the units and streams set up above; electricity (an “other” activity) is passed at creation as a total quantity over the lifetime. We analyze both plants over a 20-year lifetime.

5.1. Reading impacts

Each LCA is linked to a System, like a TEA. We create one per plant and pass the lifetime electricity use:

[30]:
lifetime = 20
# total electricity over the lifetime (kWh) = rate (kW) x hours; the aerobic plant aerates
aer_kWh = aer.power_utility.rate * 24 * 365 * lifetime
ana_kWh = ana.power_utility.rate * 24 * 365 * lifetime
lca_aer = qs.LCA(system=aer_sys, lifetime=lifetime, electricity=aer_kWh, simulate_system=False)
lca_ana = qs.LCA(system=ana_sys, lifetime=lifetime, electricity=ana_kWh, simulate_system=False)
lca_aer.show()
LCA: aer_sys (lifetime 20 yr)
Impacts:
                              Construction  Transportation   Stream   Others    Total
FossilEnergyConsumption (MJ)      1.02e+06        2.53e+05        0 3.54e+07 3.66e+07
GlobalWarming (kg CO2-eq)          1.5e+05        1.26e+04 5.05e+05 2.98e+06 3.65e+06

show() prints the breakdown by category. get_total_impacts returns the totals as a dict (the headline result); pass annual=True to divide by the lifetime, or operation_only=True to drop the one-time construction impacts:

[31]:
print('total     :', {k: round(v) for k, v in lca_aer.get_total_impacts().items()})
print('annual    :', {k: round(v) for k, v in lca_aer.get_total_impacts(annual=True).items()})
print('operation :', {k: round(v) for k, v in lca_aer.get_total_impacts(operation_only=True).items()})
total     : {'FossilEnergyConsumption': 36639655, 'GlobalWarming': 3650107}
annual    : {'FossilEnergyConsumption': 1831983, 'GlobalWarming': 182505}
operation : {'FossilEnergyConsumption': 35619655, 'GlobalWarming': 3500107}

Each category also has a getter and a total_*_impacts shortcut, handy for contribution analysis:

[32]:
print('construction:', {k: round(v) for k, v in lca_aer.total_construction_impacts.items()})
print('transport   :', {k: round(v) for k, v in lca_aer.total_transportation_impacts.items()})
print('stream      :', {k: round(v) for k, v in lca_aer.total_stream_impacts.items()})
print('other       :', {k: round(v) for k, v in lca_aer.total_other_impacts.items()})
construction: {'FossilEnergyConsumption': 1020000, 'GlobalWarming': 150000}
transport   : {'FossilEnergyConsumption': 252542, 'GlobalWarming': 12627}
stream      : {'FossilEnergyConsumption': 0, 'GlobalWarming': 505430}
other       : {'FossilEnergyConsumption': 35367113, 'GlobalWarming': 2982050}

For a multi-unit system, get_unit_impacts returns the impacts attributable to specific units (here the plant is the whole system):

[33]:
{k: round(v) for k, v in lca_aer.get_unit_impacts((aer,)).items()}
[33]:
{'FossilEnergyConsumption': 36639655, 'GlobalWarming': 3650107}

5.2. Impact tables

get_impact_table returns a per-item breakdown for a category. These four tables (construction, transportation, stream, and other) are exactly what save_report writes to the LCA sheet, so it is worth inspecting them first.

[34]:
lca_aer.get_impact_table('Construction')
[34]:
Quantity Item Ratio FossilEnergyConsumption [MJ] Category FossilEnergyConsumption Ratio GlobalWarming [kg CO2-eq] Category GlobalWarming Ratio
Construction SanUnit
Concrete [m3] aer 300 1 2.7e+05 0.265 9e+04 0.6
Total 300 1 2.7e+05 0.265 9e+04 0.6
Steel [kg] aer 3e+04 1 7.5e+05 0.735 6e+04 0.4
Total 3e+04 1 7.5e+05 0.735 6e+04 0.4
Sum All 1.02e+06 1 1.5e+05 1
[35]:
lca_aer.get_impact_table('Transportation')
[35]:
Quantity Item Ratio FossilEnergyConsumption [MJ] Category FossilEnergyConsumption Ratio GlobalWarming [kg CO2-eq] Category GlobalWarming Ratio
Transportation SanUnit
Trucking [kg*km] aer 1.26e+08 1 2.53e+05 1 1.26e+04 1
Total 1.26e+08 1 2.53e+05 1 1.26e+04 1
Sum All 2.53e+05 1 1.26e+04 1
[36]:
lca_aer.get_impact_table('Stream')
[36]:
Mass [kg] FossilEnergyConsumption [MJ] Category FossilEnergyConsumption Ratio GlobalWarming [kg CO2-eq] Category GlobalWarming Ratio
Stream
aer_sludge 2.53e+06 0 0 5.05e+05 1
Sum 0 1 5.05e+05 1
[37]:
lca_aer.get_impact_table('Other')
[37]:
Quantity FossilEnergyConsumption [MJ] Category FossilEnergyConsumption Ratio GlobalWarming [kg CO2-eq] Category GlobalWarming Ratio
Other
electricity 5.96e+06 3.54e+07 1 2.98e+06 1
Sum 3.54e+07 1 2.98e+06 1

Every table and save_report also accept a time frame: annual=True (per year), or the more general time_frame= ('lifetime', the default, or 'yr', 'month', 'day', 'hr'). The column unit suffix updates accordingly. Because operating impacts scale linearly with time, you can also divide any result by a period yourself to normalize however you like:

[38]:
print('per year :', round(lca_aer.get_total_impacts(time_frame='yr')['GlobalWarming']), 'kg CO2-eq/yr')
print('per day  :', round(lca_aer.get_total_impacts(time_frame='day')['GlobalWarming']), 'kg CO2-eq/day')
lca_aer.get_impact_table('Construction', time_frame='yr')   # the same table, on a per-year basis
per year : 182505 kg CO2-eq/yr
per day  : 500 kg CO2-eq/day
[38]:
Quantity/yr Item Ratio FossilEnergyConsumption [MJ/yr] Category FossilEnergyConsumption Ratio GlobalWarming [kg CO2-eq/yr] Category GlobalWarming Ratio
Construction SanUnit
Concrete [m3] aer 15 1 1.35e+04 0.265 4.5e+03 0.6
Total 15 1 1.35e+04 0.265 4.5e+03 0.6
Steel [kg] aer 1.5e+03 1 3.75e+04 0.735 3e+03 0.4
Total 1.5e+03 1 3.75e+04 0.735 3e+03 0.4
Sum All 5.1e+04 1 7.5e+03 1

For streams, get_stream_impacts separates positive direct emissions from negative offsets (avoided impacts). The anaerobic plant’s biogas is an offset, so it appears as a credit:

[39]:
print('anaerobic direct emissions:', lca_ana.get_stream_impacts(kind='direct_emission'))
print('anaerobic offsets         :', lca_ana.get_stream_impacts(kind='offset'))
anaerobic direct emissions: {'FossilEnergyConsumption': 0.0, 'GlobalWarming': 63178.74956710877}
anaerobic offsets         : {'FossilEnergyConsumption': -358108759.84849435, 'GlobalWarming': -14324350.393939773}

5.3. Allocating impacts to streams

When a system makes several products (or has several waste streams), you often want to attribute the footprint to a particular stream. The workflow has two parts:

  1. Exclude the streams of interest from the total with get_total_impacts(exclude_streams=...), so their own assigned impacts are not double-counted; and

  2. Allocate that remaining total to one or more chosen streams with get_allocated_impacts (a dict) or get_allocated_impact_table (a table with an allocation-factor column).

This is the LCA counterpart to TEA.solve_price: there you isolate one stream’s economic role, here its share of the environmental footprint.

[40]:
# the total with the two outlets' own impacts removed (this is what gets allocated)
{k: round(v) for k, v in lca_aer.get_total_impacts(exclude_streams=aer.outs).items()}
[40]:
{'FossilEnergyConsumption': 36639655, 'GlobalWarming': 3144677}

By default the impacts are allocated by mass. The effluent carries almost all the mass, so it receives almost all of the allocated impact:

[41]:
lca_aer.get_allocated_impact_table(aer.outs, allocate_by='mass')
[41]:
FossilEnergyConsumption [MJ] GlobalWarming [kg CO2-eq] Allocation factor
Stream
aer_effluent 3.66e+07 3.14e+06 1
aer_sludge 3.18e+03 273 8.67e-05

allocate_by also accepts 'energy' (by heating value), 'value' (by price), or your own iterable of ratios (no need to normalize). Choose the basis that matches your accounting. For example, an explicit 60/40 split:

[42]:
lca_aer.get_allocated_impact_table(aer.outs, allocate_by=(0.6, 0.4))
[42]:
FossilEnergyConsumption [MJ] GlobalWarming [kg CO2-eq] Allocation factor
Stream
aer_effluent 2.2e+07 1.89e+06 0.6
aer_sludge 1.47e+07 1.26e+06 0.4

Choosing a basis. 'energy' requires the streams to have a heating value and 'value' requires them to have a price; pick the basis that reflects how the burden should be shared among coproducts. get_allocated_impacts returns the same numbers as a nested dict if you prefer to work with them directly.

Normalizing to a functional unit. The counterpart to TEA.solve_price (a stream’s cost per unit) is the footprint per functional unit. get_normalized_impacts divides the impacts by the throughput of the reference stream(s): for a treatment plant the natural unit is per m³ of wastewater treated. Pass normalize_by='mass' (per kg), 'volume' (per m³), or 'energy' (per MJ). By default it normalizes the total impacts; pass allocate_by to instead normalize the impacts allocated to those streams (per kg of a product). Just as dividing by time gives a per-year number, this gives a per-m³ or per-kg number, so you can normalize to whatever basis your study needs.

[43]:
# per m3 of wastewater treated (total impacts / influent volume): the plant's functional unit
print('GWP per m3 treated:',
      round(lca_aer.get_normalized_impacts(aer.ins[0], normalize_by='volume')['GlobalWarming'], 3),
      'kg CO2-eq/m3')

# or normalize the impacts *allocated* to the outlet streams, per kg of their combined flow
alloc_per_kg = lca_aer.get_normalized_impacts(aer.outs, normalize_by='mass', allocate_by='mass')
print('GWP per kg outflow:', '%.2e' % alloc_per_kg['GlobalWarming'], 'kg CO2-eq/kg')
GWP per m3 treated: 0.125 kg CO2-eq/m3
GWP per kg outflow: 1.08e-04 kg CO2-eq/kg

5.4. Quantities that change with simulation

For an “other” item whose quantity depends on the simulation (for example, electricity that changes when you adjust the plant), pass a function instead of a number. refresh_other_items re-evaluates it, so the LCA stays in sync after you change the system:

[44]:
# pass a function so the electricity quantity tracks the simulation
lca_aer.add_other_item('electricity', lambda: aer.power_utility.rate*24*365*lifetime)
feed = aer.ins[0]
base_substrate = feed.imass['Substrate']
before = lca_aer.get_total_impacts()['GlobalWarming']

feed.imass['Substrate'] = base_substrate*1.5    # 50% stronger influent -> more aeration
aer_sys.simulate(); lca_aer.refresh_other_items()
after = lca_aer.get_total_impacts()['GlobalWarming']
print(f'GWP before: {before:,.0f}  |  after +50% COD: {after:,.0f}')

feed.imass['Substrate'] = base_substrate        # restore the base case
aer_sys.simulate()
lca_aer.refresh_other_items()
GWP before: 3,650,107  |  after +50% COD: 5,393,847

Tip. Any LCA quantity (an “other” item, and also any Construction or Transportation quantity) can be a zero-argument callable (function) rather than a fixed number; the LCA reads it lazily (i.e., doesn’t run until needed) on each impact query. The callable can equivalently be passed straight to the qs.LCA constructor, skipping the follow-up add_other_item call:

lca_aer = qs.LCA(
    system=aer_sys, lifetime=lifetime,
    electricity=lambda: aer.power_utility.rate * 24 * 365 * lifetime,
    simulate_system=False,
)

Use whichever form reads more clearly for the situation. The combined form is the natural choice when the LCA is wrapped in an uncertainty Model whose parameters change the system between samples; the 9. Uncertainty and Sensitivity Analyses tutorial uses it for exactly that reason.

Operating over the year. When the system runs in several modes across the year, such as seasonal load, day and night, or feedstock switching, an AgileSystem gives one annualized LCA weighted by operating hours. See Operational flexibility in Tutorial 6.

5.5. Comparing systems and exporting

In the TEA tutorial the aerobic plant was more cost-effective for this dilute municipal wastewater; the LCA tells a different story, because the anaerobic plant’s biogas recovery offsets fossil energy and gives it a much lower (here net-negative) carbon footprint. Cost and carbon do not always agree:

[45]:
import pandas as pd
rows = []
for name, lca in (('aerobic', lca_aer), ('anaerobic', lca_ana)):
    tot = lca.get_total_impacts()
    rows.append((name, round(tot['GlobalWarming']), round(tot['FossilEnergyConsumption'])))
pd.DataFrame(rows, columns=['plant', 'GWP (kg CO2-eq)', 'FEC (MJ)'])
[45]:
plant GWP (kg CO2-eq) FEC (MJ)
0 aerobic 3650107 36639655
1 anaerobic -14059593 -356717192

Finally, export everything to one Excel workbook with save_report. It is unified across System, TEA, and LCA, so aer_sys.save_report(...), a TEA’s save_report(...), and lca_aer.save_report(...) all write the same file (system design, costs, utilities, and the LCA tables above on an LCA sheet). For the LCA tables only, use get_impact_table as shown in Section 5.2.

[46]:
# lca_aer.save_report('aer_report.xlsx')

5.6. Cost and environmental trade-off

Section 5.5 compared the two plants on environmental impact, and the 7. TEA tutorial compared them on cost. The decision a user actually faces puts the two together: which option wins on cost and on carbon?

Because both analyses use the same two plants treating the same wastewater, we can line up a cost metric and an impact metric on the same functional unit (per m³ of wastewater treated) in one table. We reuse the break-even treatment fee from the TEA tutorial (the per-m³ fee that brings NPV to zero) and the GWP per m³ from Section 5.4:

[47]:
import pandas as pd

qs.PowerUtility.price = 0.08   # USD/kWh, matching the TEA tutorial

rows = []
for name, sys_, u, lca in (('aerobic', aer_sys, aer, lca_aer),
                           ('anaerobic', ana_sys, ana, lca_ana)):
    tea = qs.TEA(sys_, discount_rate=0.05, lifetime=20)
    fee = -tea.solve_price(u.ins[0]) * 1000      # USD/m3, break-even treatment fee
    gwp = lca.get_normalized_impacts(u.ins[0], normalize_by='volume')['GlobalWarming']
    rows.append((name, round(fee, 2), round(gwp, 3)))
pd.DataFrame(rows, columns=['plant', 'break-even fee (USD/m3)', 'GWP (kg CO2-eq/m3)'])
[47]:
plant break-even fee (USD/m3) GWP (kg CO2-eq/m3)
0 aerobic 0.3 0.125
1 anaerobic 0.83 -0.481

The two metrics point in opposite directions: the aerobic plant is cheaper to run for this dilute municipal wastewater, while the anaerobic plant has the lower (here net-negative) carbon footprint thanks to its biogas credit. No single option wins on both, so the choice depends on what a project weights more heavily, cost or carbon, and on context such as the wastewater strength (the TEA tutorial shows the cost ranking itself flips for stronger wastewater) or a price on carbon. Putting both metrics on the same per-m³ basis is what makes the trade-off legible.

Comparing like with like. A comparison across systems is only meaningful when both share the same functional unit and the same system boundary. Here both plants treat the same wastewater (4,000 m³/d, COD about 430 mg/L) and both metrics are expressed per m³ treated, so the rows can be read across directly. If the systems treated different flows, or you drew the boundary differently (for example, crediting recovered biogas in one but not the other), normalize to a common functional unit (Section 5.4) and align the boundaries first.


↑ Back to top