Skip to main content

Country-specific characterization factors for the Brightway LCA framework

Project description

TRAILS: Temporal Routing and Aggregation of Impacts across Life-cycle Systems

PyPI version Conda version License Tests

TRAILS is a Python library for temporal Life Cycle Assessment (LCA). It models time-resolved supply chains where technosphere and biosphere exchanges can occur at different points in time and across scenario years. This makes it possible to compute how impacts evolve over time, attribute them to responsible activities, and compare scenarios.

Online documentation: https://trails.readthedocs.io/en/latest/

At a high level, TRAILS:

  • Effortlessly handles deep temporalization — temporal distributions can occur at any level of the supply chain, not just the foreground model.
  • Loads 3D technosphere/biosphere matrices (time, activity, products) from a Frictionless data package.
  • Interpolates scenario matrices data points to annual resolution.
  • Solves the inventory sequentially, year by year, avoiding a single massive technosphere solve.
  • Runs a temporal traversal of the supply chain from a functional unit to build time-indexed demands.
  • Supports adaptive temporal routing using a relative static LCIA score-potential cutoff, so low-potential branches can remain matrix-solved frontier demands instead of being expanded explicitly.
  • Solves year-specific systems and routes impacts through temporal distributions.
  • Can score temporal inventories with optional EDGES regionalized characterization factors.
  • Aggregates impacts by year, activity, and optional root attribution for analysis and plotting.

TRAILS is initially designed to consume premise-generated data packages, which provide year-specific background inventories and temporal distributions. This enables a single, deeply-temporalized, technosphere representation.

TRAILS is compatible with Frictionless data packages produced by premise.

Algorithm Overview

flowchart TD
  A["Functional unit: start year, activity, product demand"] --> B["Map year and convert product demand to activity amount"]
  B --> C{"Adaptive routing enabled?"}
  C -->|Yes| D["Load static activity scores and compute effective cutoff"]
  C -->|No| E["Use fixed max depth"]
  D --> F["Initialize graph, queue, frontier buckets, and direct-bio buckets"]
  E --> F
  F --> G{"Queue empty?"}
  G -->|No| H["Pop node, map year to scenario year, and add amount to node"]
  H --> I{"Current node reached max depth?"}
  I -->|Yes| J["Record node frontier: max depth"]
  I -->|No| K["Read technosphere row and skip production exchange"]
  K --> L["Create child demands from non-temporal, ported temporal, or matrix temporal exchanges"]
  L --> M{"Any child demands?"}
  M -->|No| N["Record node frontier: leaf"]
  M -->|Yes| O["For expanded non-root nodes, store direct-bio supply amount for LCA"]
  O --> P["Create child nodes and graph edges"]
  P --> Q{"Stop child branch?"}
  Q -->|max depth| R1["Record child frontier: max depth"]
  Q -->|below min amount| R2["Record child frontier: min amount"]
  Q -->|adaptive cutoff| R3["Record child frontier: adaptive cutoff"]
  Q -->|No| R4["Enqueue child and carry first-level root attribution"]
  J --> G
  N --> G
  R1 --> G
  R2 --> G
  R3 --> G
  R4 --> G
  G -->|Yes| S["Build NetworkX routing graph with frontier, root, and score attributes"]
  S --> T["LCA reads graph frontier amounts and direct-bio marks"]
  T --> U["Convert frontier amounts to demand vectors by solve year"]
  U --> V{"Root attribution enabled?"}
  V -->|Yes| W["Build one RHS per root and reuse factorization per year"]
  V -->|No| X["Build one RHS per year and solve year-specific technosphere"]
  W --> Y["Accumulate temporalized biosphere inventory and scores from solved supplies"]
  X --> Y
  T --> Z["Inject functional-unit supply and direct-bio supplies"]
  Z --> AA["Apply biosphere temporal distributions during LCA"]
  AA --> Y

Caption: Temporal technosphere distributions first produce raw pulse years (year + offset). Routing maps those years to available scenario years by clipping to the model horizon and, on non-annual grids, snapping to the nearest scenario year. The field temporal_amount_source controls how amounts are applied over time: port splits the anchor-year exchange amount across pulse weights, while matrix rereads the exchange coefficient from each pulse scenario year before applying the weight. Branches stopped by max_depth, min_amount, leaf status, or the adaptive score-potential cutoff remain frontier demand and are still solved by lca(). Routing does not temporalize direct biosphere exchanges itself; it records direct-bio supply amounts for expanded non-root nodes, and lca() applies biosphere temporal distributions while accumulating inventories and scores.


Example Outputs

Example output for a gasoline passenger car driven 200,000 km (reference year 2050) with a prospective background: the temporal supply-chain graph above, followed by the resulting GWP, radiative forcing, and temperature anomaly time series.


Temporal GWP100

Radiative forcing

Temperature anomaly

Example Notebooks

Tutorial notebooks are available under examples/:

  • examples/1. simple numerical example.ipynb
  • examples/2.1. generate Trails data package.ipynb
  • examples/2.2. premise and imported lci example.ipynb
  • examples/2.3. fixed depth vs adaptive routing imported lci.ipynb

These walk through a full workflow (data loading, routing, LCA, plotting, and FaIR-based climate metrics).


Usage

Below is a minimal example that loads a Frictionless data package, runs a temporal LCA, and plots the resulting impact time series.

from datapackage import Package

from trails import (
    Trails,
    lca,
    get_lcia_method_names,
    plot_temporal_scores,
    plot_adaptive_sankey,
)

# Load a Frictionless data package exported by premise (or compatible tooling)
package = Package("path/to/datapackage.json")

# Choose an LCIA method bundled with TRAILS
method = get_lcia_method_names(ei_version="3.11")[0]

# Initialize TRAILS with annual interpolation.
# By default, annual years are extended by one year on each side
# (min_year-1 to max_year+1) using endpoint duplication.
trails = Trails(
    package,
    interpolate_annual=True,
    methods=[method],
    ei_version="3.11",
)

# Optional: wider padding, e.g., 20 years before/after
# trails = Trails(
#     package,
#     interpolate_annual=True,
#     interpolation_start_year_offset=-20,
#     interpolation_end_year_offset=20,
#     methods=[method],
#     ei_version="3.11",
# )

# Pick an activity index from the metadata
activity_indices = next(iter(trails.activity_indices.values()))
start_act_idx = next(iter(activity_indices.keys()))

# Run temporal routing (builds the traversal graph).
# By default this uses adaptive routing with a relative cutoff of 1e-4.
trails.temporal_routing(
    start_year=2030,
    start_act_idx=start_act_idx,
)

# Run temporal LCA (stores scores on trails.scores)
lca(
    trails=trails,
    # defaults shown explicitly:
    solver_mode="iterative",
    iterative_rtol=1e-3,
)

# Plot temporal impact scores
fig = plot_temporal_scores(trails, method_label=method)
fig.show()

# Plot the explicit adaptive routed graph as a depth/year Sankey
sankey = plot_adaptive_sankey(
    trails,
    method=method,
    branch_visual_cutoff=0.001,
)
sankey.show()

Temporal routing modes

temporal_routing() is adaptive by default. The default cutoff is a relative score-potential cutoff of 1e-4, meaning that a branch can stop being expanded explicitly once its estimated static score potential is at most 0.01% of the functional unit's static score potential. Stopped branches remain frontier demands and are still included in the year-wise matrix solve.

# 1. Default adaptive routing
trails.temporal_routing(start_year=2030, start_act_idx=start_act_idx)

# 2. Adaptive routing with a different relative cutoff
trails.temporal_routing(
    start_year=2030,
    start_act_idx=start_act_idx,
    adaptive_relative_score_cutoff=1e-5,
)

# 3. Adaptive routing with a hard depth cap
trails.temporal_routing(
    start_year=2030,
    start_act_idx=start_act_idx,
    max_depth=5,
    adaptive_relative_score_cutoff=1e-4,
)

# 4. Fixed-depth routing
trails.temporal_routing(
    start_year=2030,
    start_act_idx=start_act_idx,
    max_depth=3,
)

Adaptive routing requires regular LCIA methods, usually provided once with Trails(..., methods=[method], ei_version="..."). EDGES methods are final-score methods only; EDGES-only workflows should use fixed-depth routing or also provide regular methods for adaptive routing before EDGES final scoring.


Adaptive Sankey plotting

plot_adaptive_sankey() visualizes the explicit graph created by adaptive temporal_routing(). Link widths use the routed child node's static score-potential contribution, nodes are arranged horizontally by routing depth and vertically by year, and labels are available on hover. Matrix-solved frontier demands are included in the LCA, but only explicitly routed graph edges appear in the Sankey.

from trails import plot_adaptive_sankey

trails.temporal_routing(
    start_year=2030,
    start_act_idx=start_act_idx,
    adaptive_relative_score_cutoff=1e-4,
)

fig = plot_adaptive_sankey(
    trails,
    method=method,
    branch_visual_cutoff=0.001,
    max_sankey_links=0,  # no hard link cap
    output_path="adaptive_sankey.html",
)
fig.show()

The same plot is also available as a convenience method:

fig = trails.plot_adaptive_sankey(method=method)

Optional EDGES regionalized LCIA

TRAILS can score the finalized temporal inventory with EDGES edge-level characterization factors. This is optional; normal LCIA methods do not require the edges package.

from trails import Trails, get_edges_lcia_method_names

edges_method = get_edges_lcia_method_names()[0]

trails_edges = Trails(
    package,
    interpolate_annual=True,
    edges_methods=[edges_method],
)

lca(
    trails=trails_edges,
    edges_reuse_cached_cfs=True,
)

edges_methods is mutually exclusive with regular methods for final scoring. Constructor methods can still be used as regular LCIA proxy methods for adaptive routing before final EDGES scoring. With the default edges_reuse_cached_cfs=True, TRAILS reuses EDGES matched CF templates across scenario years when supplier and consumer metadata signatures are identical, while still evaluating numeric CF values for each year. Set edges_reuse_cached_cfs=False to force EDGES matching independently for every year, for example if an EDGES method has year-specific matching rules or year-specific CF definitions.


Importing Excel Inventories

You can import user-provided inventories from Excel using bw2io.

from trails import Trails

trails = Trails(package)
trails.import_excel_inventory("path/to/inventory.xlsx")

# Target a single scenario slice instead
trails.import_excel_inventory("path/to/inventory.xlsx", year=2020)

Year-specific amounts

You can provide year-specific amounts directly in the Excel exchanges by adding integer year columns (e.g., 2010, 2020, 2030, 2050). These values are written to the corresponding years in A/B, and TRAILS interpolates between them across annual years as usual. If no year-specific columns are present, the importer uses the standard amount field.

FaIR Climate Model Integration

TRAILS can translate time-resolved inventories into radiative forcing and temperature anomalies using the FaIR climate model. The workflow runs a baseline FaIR scenario and performs per-species perturbations derived from the Trails inventory. Positive and negative emissions are treated separately to preserve long-lived CO2 tails for both uptake and release. Results are allocated to root activities using cumulative signed emissions for each (flow, root) pair and stored as trails.instant_radiative_forcing and trails.delta_temperature.

Key components:

  • Emissions baseline from the bundled REMIND/FaIR IAMC CSV
  • Flow-to-species mapping via data/scenarios/fair_species_map.yaml
  • Per-species FaIR runs with optional auto-scaling
  • All FaIR configs are evaluated; quantiles (2.5, 25, 50, 75, 97.5) are stored
  • Output dims: (quantile, year, flow, root activity)
  • Units: W/m² for radiative forcing and °C for temperature anomaly

Example:

from trails.fair_rf import run_fair_delta_rf
from trails import plot_rf, plot_temp

rf = run_fair_delta_rf(
    trails,
    scenario="REMIND|SSP2-PkBudg650",
    # defaults shown explicitly:
    per_species_runs=True,
    per_species_workers=None,  # auto: min(4, cpu_count, n_work_items)
)

# Quantile outputs are stored on the Trails instance
rf = trails.instant_radiative_forcing  # (quantile, year, flow, root activity)
temp = trails.delta_temperature        # (quantile, year, flow, root activity)

# Plotting defaults to the 50th quantile
plot_rf(trails, year_range=(2000, 2100))
plot_temp(trails, year_range=(2000, 2100))

Notes:

  • run_fair_delta_rf requires trails.inventory with root activity attribution. Run lca(..., store_inventory=True) after temporal_routing(...) before calling FaIR.
  • scenario must match a scenario label present in the emissions CSV used by run_fair_delta_rf (bundled default uses REMIND/FaIR data).
  • If you don't pass config_name or config_names, TRAILS evaluates all available FaIR configurations and stores quantiles across the ensemble.

Method Overview

TRAILS extends classic LCA by making time an explicit dimension. Temporal exchanges are encoded using distributions (e.g., discrete, normal, lognormal, uniform, triangular, discrete empirical) and expanded into year offsets during traversal. For each calendar year that becomes active in the traversal frontier, the system matrix is solved, and biosphere flows are accumulated at their respective years. Impacts are then characterized using LCIA methods bundled with the library, producing time series of impact scores.

The key modeling steps are:

  1. Load package data: technosphere/biosphere matrices and metadata.
  2. Temporal traversal: propagate demands across time using exchange distributions.
  3. Per-year solving: build year-specific systems and compute supply vectors.
  4. Impact attribution: accumulate impacts by year and (optionally) by root activity.

Motivation

Conventional LCA frameworks treat time implicitly or exogenously. Impacts are typically computed for a single static system, even when future scenarios or dynamic technologies are considered.

TRAILS addresses this limitation by introducing:

  • Handling of temporal dimensions in technosphere and biosphere matrices
  • Time-aware routing of exchanges across supply chains
  • Scenario-dependent inventories and impacts

Instead of asking “What is the impact of this system?”, TRAILS allows you to ask:

When do impacts occur across the life cycle?


Core Concepts

1. Temporal graph traversal

Life-cycle systems are represented as time-indexed graphs, where exchanges may occur at different points in time relative to the functional unit.

2. Routing of impacts

Impacts are routed along supply-chain paths, allowing attribution to:

  • specific suppliers,
  • specific time periods,
  • specific traversal depths.

3. Aggregation across scenarios and horizons

Impacts can be aggregated or compared across:

  • years (e.g., 2020 → 2050 → 2100),
  • scenarios (e.g., SSPs, decarbonization pathways),
  • temporal horizons (short-term vs long-term effects).

Key Features

  • Temporal LCA engine with explicit time handling
  • Deep supply-chain traversal
  • Scenario-aware computation and aggregation across years

Data Package Expectations

TRAILS consumes Frictionless data packages with:

  • Matrices: technosphere (A) and biosphere (B) CSVs with required columns such as index of activity, index of product / index of biosphere flow, value, and uncertainty fields (loc, scale, shape, minimum, maximum, negative, flip).
  • Temporal columns (optional): temporal_distribution, temporal_loc, temporal_scale, temporal_min, temporal_max, temporal_amount_source, temporal_offsets, temporal_weights.
  • Metadata: activity and biosphere indices per scenario label (year).

Packages exported by the premise.TrailsDataPackage class follow this structure out of the box.

Architecture Overview

Core modules and responsibilities:

  • trails/datapackage.py: load matrices, indices, and temporal metadata.
  • trails/trails.py: main wrapper, temporal traversal, inventory/score accumulation.
  • trails/lca.py: orchestration of traversal + per-year solves (iterative, direct, or bw2calc; default is iterative).
  • trails/lcia.py: bundled LCIA methods and characterization factor matrices.
  • trails/plotting.py: time-series visualization helpers.

FAQ

What is a temporal exchange?
An exchange with a distribution over year offsets (e.g., lognormal), expanded into discrete year pulses during traversal.

How do I encode explicit pulses in specific years?
Use temporal_distribution=6 with JSON-list columns: temporal_offsets (e.g., [0, 5, 12]) and temporal_weights (e.g., [0.5, 0.3, 0.2]).

How are years handled?
Scenario labels are treated as calendar years. When a year is requested that does not exist in the package, the nearest available scenario year is used.

Do I need both scores and inventory?
By default lca() computes scores and stores them on trails.scores. If you set store_inventory=True, TRAILS also stores trails.inventory. If compute_score=True at the same time, trails.characterized_inventory is also available. Remember to run trails.temporal_routing(...) before lca().

Limitations & Assumptions

  • Input data must follow the expected Frictionless schema; missing columns will fail fast.
  • Years are treated as discrete calendar years (no sub-annual resolution).
  • If a requested year is not available, the nearest scenario year is used.
  • Some tests or workflows may require external LCA data (e.g., ecoinvent) not shipped here.

Installation

pip install trails-lca

or:

conda install -c conda-forge -c romainsacchi trails-lca

The PyPI and conda distributions are named trails-lca; the Python import package remains trails.


Solver Performance Notes

TRAILS defaults to an iterative GMRES solve (solver_mode="iterative") with iterative_rtol=1e-3. You can also use solver_mode="bw2calc" or solver_mode="direct" depending on your workflow.

For bw2calc and direct sparse-factorization paths, performance depends on the available sparse solver backend:

  • PC users: bw2calc will use pypardiso with MKL’s PARDISO solver (fast).
  • Mac users with ARM chips: install scikit-umfpack to enable UMFPACK. Without it, the solver falls back to SciPy’s default, which is significantly slower.

To enable pypardiso on PCs:

pip install pypardiso

or, using conda:

conda install -c conda-forge pypardiso

To enable UMFPACK on ARM Macs:

pip install scikit-umfpack

or, using conda:

conda install -c conda-forge scikit-umfpack

Documentation

https://trails.readthedocs.io/en/latest/index.html


Authors

License

MIT License.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

trails_lca-1.0.1.tar.gz (14.7 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

trails_lca-1.0.1-py3-none-any.whl (15.2 MB view details)

Uploaded Python 3

File details

Details for the file trails_lca-1.0.1.tar.gz.

File metadata

  • Download URL: trails_lca-1.0.1.tar.gz
  • Upload date:
  • Size: 14.7 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for trails_lca-1.0.1.tar.gz
Algorithm Hash digest
SHA256 b38546810a8d8de2b1428fb6943592b45f74482c649baf7f576ddc548a0d19f0
MD5 13ced1e843d31ccc7c583a2b46334a1a
BLAKE2b-256 017ac726f576756f384ab9c02cd8f692d57fa0449060ea6f5c2e9d3358f1b715

See more details on using hashes here.

File details

Details for the file trails_lca-1.0.1-py3-none-any.whl.

File metadata

  • Download URL: trails_lca-1.0.1-py3-none-any.whl
  • Upload date:
  • Size: 15.2 MB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for trails_lca-1.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 f2c60dc085c7f96bf8adcb05225442b7068d43a07b264097cc2aef6e3f77aa76
MD5 d200d113bf40eec9d2e948d2f0e6b912
BLAKE2b-256 12ecceefdd1e9d3c8873b15bdc8bc7cde13a47281eb774a529710912476779b4

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page