Life Cycle Assessment in Python: What I Learned with Brightway
A technical walkthrough of LCA calculations, matrix math, and practical workflows from scratch to Monte Carlo
I was part of a five day intensive course with CIRAIG/DTU, followed by a three-day course in Barcelona, I spent a lot of time pulling apart how Brightway actually works under the hood. Here is the colloaction of my thoughts and summary of these courses.
What is LCA, Briefly
Life Cycle Assessment (LCA) quantifies the environmental impact of a product or service across its entire life — from raw material extraction to end-of-life disposal. It’s structured into four phases (ISO 14040/44):
1. Goal and scope — what you’re studying and why
2. Life Cycle Inventory (LCI) — collecting all inputs, outputs, and emissions
3. Life Cycle Impact Assessment (LCIA)— translating flows into impact scores (e.g., CO₂ → kg CO₂-eq)
4. Interpretation — identifying hotspots and drawing conclusions
The interesting part computationally is steps 2 and 3, which reduce to matrix algebra. Once you see that, Brightway’s design becomes obvious.
The Matrix Behind LCA
Before touching any code, it’s worth understanding what’s actually being calculated. The formulation comes from Heijungs & Suh (2002) and uses three core objects:
- A — the _technosphere matrix_: square, one column per activity. The diagonal holds production amounts; off-diagonal entries are technosphere inputs (negative by convention).
- B — the _intervention matrix_: rows = elementary flows (emissions, resource use), columns = activities.
- f — the _demand vector_: what you’re asking the system to produce (your functional unit).
The LCA calculation then proceeds in two steps:
Step 1 — Life Cycle Inventory: solve As = f to get the scaling vector s, then compute the inventory g = Bs.
Step 2 — Impact assessment: multiply by a characterization factor vector q to get the score: h = q^T g = q^T B A^{-1} f
Here’s this done by hand on a tiny two-process system (coal power plant + coal mining):
import numpy as np
import pandas as pd
products = ['Electricity [kWh]', 'Coal [kg]']
activities = ['Coal power plant', 'Coal mining']
flows = ['CO2, fossil [kg]', 'CH4, fossil [kg]']
# Production on diagonal (positive), inputs off-diagonal (negative)
A = np.array([[1.0, -0.025],
[-0.4, 1.0]])
B = np.array([[1.04, 0.3],
[0.0, 0.05]])
f = np.array([1.0, 0.0]) # demand: 1 kWh of electricity
q = np.array([1.0, 28.0]) # GWP100 CFs: CO2=1, CH4=28
s = np.linalg.solve(A, f) # scaling vector
g = B @ s # aggregated inventory
h = q @ g # impact score
print(f"Score: {h:.3f} kg CO2-eq per kWh")Why Keep Matrices Instead of Collapsing to a Scalar?
Why Keep Matrices Instead of Collapsing to a Scalar?
The compact formula gives you `h` — one number. But if you preserve the intermediate matrix form `G = B · diag(s)` and `H = Q · G` (where Q = diag(q)), each cell of H tells you which _activity_ contributed which _characterized impact_. That’s contribution analysis.
G = B @ np.diag(s) # inventory matrix: flows × activities
Q = np.diag(q)
H = Q @ G # characterized inventory: same shape, in impact units
# H.sum() == h (same total score)
# but now each column shows one activity's contribution:
pd.DataFrame(H, index=flows, columns=activities)This is the conceptual bridge to Brightway: the software builds A and B from your database, solves the system with sparse LU factorization (not `np.linalg.inv` — ecoinvent has ~20,000 activities), and keeps G and H around for contribution analysis. You can inspect them directly:
lca.lci()
lca.inventory # G matrix (sparse)
lca.characterized_inventory # H matrix (sparse)Brightway’s Data Model
Brightway organizes everything into a four-level hierarchy: Projects → Databases → Activities → Exchanges.
A project is a fully isolated silo — no data leaks between projects, which prevents accidentally mixing ecoinvent versions. A database is a collection of activities (your own foreground processes live in one too, separate from ecoinvent). An activity is a Python dictionary. An exchange is a link between two activities with an amount and a type.
import brightway2 as bw
bw.projects.set_current("my_project")
print(bw.databases)
db = bw.Database("ecoinvent 3.12 cutoff")
act = db.random()
print(act.as_dict())
# {'name': '…', 'location': 'FR', 'unit': 'MJ',
# 'reference product': '…', 'type': 'process', …}
print(act.key) # ('ecoinvent 3.12 cutoff', 'some-uuid-hex')
The key is a tuple of `(database_name, UUID)` — the unique identifier used everywhere in the SQLite database Brightway maintains on disk.
Building a Process from Scratch
The Activity Browser (Brightway’s GUI) lets you drag and drop — fast for small case studies. For anything reproducible, build in code.
import uuid
new_uuid = str(uuid.uuid4().hex)
act = bw.Database("my_foreground_db").new_activity(new_uuid)
act["name"] = "Hydrogen production, SMR"
act["unit"] = "MJ"
act["location"] = "FR"
act["reference product"] = "hydrogen"
act["type"] = "process"
act.save() # always required - nothing persists without .save()
# Find a background supplier
nat_gas = [a for a in bw.Database("ecoinvent 3.12 cutoff")
if a["name"] == "market for natural gas, high pressure"
and a["location"] == "FR"][0]
# Add a technosphere exchange
exc = act.new_exchange(
input=nat_gas.key,
amount=0.158, # m³ per MJ hydrogen (manual LHV conversion - Brightway does NOT auto-convert units)
type="technosphere"
)
exc.save()
# Add a biosphere (emission) exchange
co2 = [f for f in bw.Database("biosphere3")
if f["name"] == "Carbon dioxide, fossil"
and f["categories"] == ("air",)][0]
exc2 = act.new_exchange(
input=co2.key,
amount=0.075,
type="biosphere"
)
exc2.save()Three things that always trip people up: `.save()` must be called on every activity and exchange individually. Waste and treatment flows use negative amounts. Units are never converted automatically — if ecoinvent gives gas in m³ and you need MJ, the conversion is your responsibility.
Running an LCA Calculation
method = ("EF v3.1", "climate change", "global warming potential (GWP100)")
lca = bw.LCA(demand={act: 1}, method=method)
lca.lci() # builds A, B, solves As=f
lca.lcia() # applies characterization factors
print(lca.score) # kg CO2-eq per MJ hydrogenTo switch activities or methods without rebuilding from scratch:
lca.redo_lci({other_act: 1}) # swap activity, reuse A and B structure
lca.lcia()
print(lca.score)
lca.switch_method(other_method) # swap impact category, reuse inventory
lca.lcia()
print(lca.score)Contribution Analysis
`top_activities()` traces back through the supply chain and ranks upstream processes by their contribution to the final score:
top = lca.top_activities(limit=0.01, limit_type='percent')
df = pd.DataFrame(top, columns=['impact', 'inventory', 'name'])
print(df.head(10))Brightway has no built-in plotting — pipe results to matplotlib or export:
df.to_excel("contribution.xlsx")For foreground-specific (first-tier) Sankey diagrams, the Polyviz library (by Romain Sacchi) gives you interactive HTML visualizations directly from Brightway results.
Comparing Multiple Activities and Methods: MultiLCA
For comparing scenarios or running all impact categories at once, `MultiLCA` avoids redundant recalculation:
recipe_methods = [m for m in bw.methods if "ReCiPe 2016" in str(m)]
bw.calculation_setups["hydrogen_comparison"] = {
"inv": [{act_smr: 1}, {act_atr: 1}],
"ia": recipe_methods
}
multi = bw.MultiLCA("hydrogen_comparison")
results_df = pd.DataFrame(
multi.results,
index=[act_smr["name"], act_atr["name"]],
columns=[m[2] for m in recipe_methods]
)The DataFrame step is not optional — the raw output of `multi.results` is just a flat list of numbers with no labels.
Get Shahaf Cohen’s stories in your inbox
Join Medium for free to get updates from this writer.
One limitation: `top_activities()` contribution analysis only works for the first process in a MultiLCA. For detailed contribution analysis across all activities, use the standard `LCA` class with `redo_lci()`.
Parameters and Scenario Analysis
Hard-coding exchange amounts makes scenario analysis painful. Brightway has a three-level parameter system:
- **Project parameters** — global across all databases
- **Database parameters** — shared within a specific database
- **Activity parameters** — local to one process
from bw2data.parameters import parameters, ProjectParameter
parameters.new_project_parameters([
{"name": "density_diesel", "amount": 0.84, "comment": "kg/L"}
], overwrite=True)
[(p.name, p.amount) for p in ProjectParameter.select()]For scenario analysis, update exchange amounts directly — formulas alone don’t recalculate automatically in the Python API:
scenarios = [
{"density_diesel": 0.80},
{"density_diesel": 0.84}, # default
{"density_diesel": 0.88},
]
results = {}
default_val = dict(exchange)["amount"]
for i, scenario in enumerate(scenarios):
exchange["amount"] = scenario["density_diesel"]
exchange.save()
lca = bw.LCA({act: 1}, method)
lca.lci(); lca.lcia()
results[f"Scenario {i}"] = lca.score
# Always restore the default
exchange["amount"] = default_val
exchange.save()For switching between fundamentally different supply chain structures (e.g., gas vs. electrolysis for hydrogen), use a **switch parameter** — set the exchange amount to 0 or 1 — rather than re-searching the database each iteration.
Uncertainty: Pedigree vs. Distributions
Brightway uses the `stats_array` library to store uncertainty on exchanges. Two approaches:
Pedigree matrix — rates data quality on five criteria (reliability, completeness, temporal correlation, geographical correlation, technological representativeness) on a 1–5 scale. Brightway converts this into a log-normal distribution automatically. It’s the default in practice because you rarely have enough empirical data for a proper distribution fit.
exchange["pedigree"] = {
"reliability": 1,
"completeness": 2,
"temporal correlation": 1,
"geographical correlation": 2,
"further technological correlation": 3
}
exchange.save()**Explicit distributions** — when you have actual data to work with:
exchange["uncertainty type"] = 2 # 2 = log-normal (most common in LCA)
exchange["loc"] = 0.84 # mean in log scale
exchange["scale"] = 0.1 # geometric standard deviation
exchange.save()Log-normal is the standard in LCA because it avoids negative values — a negative fuel consumption amount is physically meaningless. Brightway supports 11 distribution types in total via `stats_array`.
Monte Carlo Simulation
With uncertainty defined on exchanges, Monte Carlo runs in a few lines:
mc = bw.MonteCarloLCA({act: 1}, method)
results = [next(mc) for _ in range(500)]
import matplotlib.pyplot as plt
plt.hist(results, bins=30, edgecolor='white')
plt.xlabel("GWP score (kg CO2-eq)")
plt.title("Monte Carlo - 500 iterations")
plt.show()One known gotcha: water impact categories sometimes produce negative values. This happens because water inflows and outflows are sampled as independent distributions, which can violate mass balance. It’s a known Brightway limitation — using a regionalized ecoinvent version (Regioinvent) with country-specific water flows largely resolves it.
Importing Databases: Excel and Beyond
Brightway is local by default — collaboration means exporting and re-importing databases. The most practical format for sharing is the Activity Browser’s Excel export, which is human-readable and editable.
from bw2io import ExcelImporter
imp = ExcelImporter("my_database.xlsx")
imp.apply_strategies() # normalize units, match categories, etc.
imp.match_database("biosphere3", fields=("name", "unit", "categories"))
imp.match_database("ecoinvent 3.12 cutoff", fields=("name", "location", "unit"))
imp.statistics()
# Check for anything that didn't link
[x for x in imp.unlinked]
# Fix names in Excel, re-run, then:
imp.write_database()The most common failure: a flow name in your Excel file doesn’t exactly match the target database. This happens across ecoinvent versions when names change. Fix the name in Excel, re-run `apply_strategies()`, and repeat until `imp.unlinked` is empty.
Activity Browser vs. Python: When to Use What
The Activity Browser accesses the exact same SQLite database as your Jupyter Notebook — any change in one is immediately visible in the other.
|Task|Use|
| - -| - -|
|Build a small inventory|Activity Browser (drag & drop is faster)|
|Verify a programmatically created process|Activity Browser|
|Automation, large imports, custom methods|Python / Jupyter Notebook|
|Scenario analysis, Monte Carlo|Python|
|Sharing your methodology|Jupyter Notebook (reproducible record)|A practical workflow: build the inventory in the Activity Browser, export to Excel, re-import via Python, run all calculations in a Notebook. You get the speed of the GUI and the reproducibility of code.
The Mental Model
Every LCA is two matrix operations — As = f and h = q^T Bs — applied to a large, sparse system. Brightway is a well-engineered wrapper that builds those matrices from a structured database, solves them efficiently, and keeps the intermediate results around so you can ask why the score is what it is, not just what it is.
Resources
- Brightway documentation: docs.brightway.dev
- Barcelona course notebooks: github.com/romainsacchi/
- Heijungs & Suh (2002): _The Computational Structure of Life Cycle Assessment_
- Brightway community forum
Written after completing the CIRAIG/DTU Brightway 2.5 course (April–May 2026) and the ILCA Barcelona Regionalized LCIA course (May 2026).




