Skip to content

LCA Processor

Time-explicit LCA data processing for optimization.

This module provides classes and utilities for performing time-explicit Life Cycle Assessment (LCA) computations using Brightway. It processes temporal distributions of product demands, constructs foreground and background inventory tensors, and prepares characterization factors for optimization.

Key Classes

  • LCAConfig: Configuration dataclass for LCA computations
  • LCADataProcessor: Main class for time-explicit LCA processing

Module Reference

Time-explicit LCA data processing for optimization.

This module provides classes and utilities for performing time-explicit Life Cycle Assessment (LCA) computations using Brightway. It processes temporal distributions of product demands, constructs foreground and background inventory tensors, and prepares characterization factors for optimization.

Key classes: - LCAConfig: Configuration for LCA computations - LCADataProcessor: Main class for time-explicit LCA processing

Classes

MetricEnum

Bases: str, Enum

Supported metrics for dynamic impact characterization.

Attributes: GWP: Global Warming Potential - time-dependent radiative forcing metric CRF: Cumulative Radiative Forcing - integrated radiative forcing over time horizon

TemporalResolutionEnum

Bases: str, Enum

Supported temporal resolutions for the optimization model.

Attributes: year: Annual time steps (currently the only supported resolution)

CharacterizationMethodConfig

Bases: BaseModel

Configuration for a single LCIA characterization method.

Attributes: category_name: User-defined identifier for the impact category (e.g., 'climate_change_dynamic_gwp'). brightway_method: Brightway method identifier tuple, either 2 or 3 elements (e.g., ('GWP', 'example') or ('IPCC', 'climate change', 'GWP 100a')). metric: Impact metric used for dynamic characterization. None implies static method. Supported values: 'GWP', 'CRF'.

Attributes

dynamic: bool property

Indicates whether this is a dynamic characterization method.

TemporalConfig

Bases: BaseModel

Configuration related to temporal aspects of the model.

Attributes: start_date: The start date of the time horizon. temporal_resolution: Temporal resolution for the model. Options: 'year', 'month', 'day'. time_horizon: Length of the time horizon (in units of temporal_resolution). fixed_time_horizon: If True, the time horizon is calculated from the time of the functional unit (FU) instead of the time of emission database_dates: Mapping from database names to their respective reference dates.

BackgroundInventoryConfig

Bases: BaseModel

Configuration for background inventory data.

Attributes: cutoff: Optional number of top elementary flows to retain per intermediate flow, ranked by absolute inventory amount. None (default) keeps all non-zero flows. restrict_to_characterized_flows: Drop elementary flows without a characterization factor in any category. retain_flows: Elementary flow codes to keep regardless of characterization. calculation_method: Method for calculating the inventory tensor. Options: 'sequential', 'parallel'. n_jobs: Number of worker processes used by the 'parallel' method. use_disk_cache: Whether calculated inventories are cached on disk between sessions. disk_cache_dir: Directory for the on-disk cache; defaults to a folder in the Brightway project. path_to_save: Optional path to save the inventory tensor. path_to_load: Optional path to load the inventory tensor.

LCAConfig

Bases: BaseModel

Configuration class for Life Cycle Assessment (LCA) data processing.

Attributes: demand: Dictionary {product_node: temporal_distribution} containing time-explicit demands for each product. Keys must be Brightway product node objects (bd.get_node(...)). temporal: Temporal configuration for model time behavior. characterization_methods: List of characterization method configurations. background_inventory: Configuration for background inventory data calculation. foreground_db_name: Name of the foreground Brightway database.

LCADataProcessor(config: LCAConfig, foreground_db_name: Optional[str] = None)

Class to perform time-explicit Life Cycle Assessment (LCA) computations and gather necessary data for building an optimization model.

This class is primarily responsible for executing the LCA-based computations required to collect all the data needed for building OptimizationModelInputs. It is reliant on Brightway2, an open-source framework for Life Cycle Assessment, to perform the calculations and retrieve LCA results.

Initialize the LCADataProcessor with the LCA configuration.

Parameters:

Name Type Description Default
config LCAConfig

The configuration object containing all settings for demand, temporal parameters, characterization methods, and background inventory.

required
foreground_db_name str

The name of the foreground Brightway database. Defaults to config.foreground_db_name, which is itself "foreground" unless set. Passing a name here overrides the one on the config.

None
Source code in src/optimex/lca_processor.py
def __init__(
    self, config: LCAConfig, foreground_db_name: Optional[str] = None
) -> None:
    """
    Initialize the LCADataProcessor with the LCA configuration.

    Parameters
    ----------
    config : LCAConfig
        The configuration object containing all settings for demand,
        temporal parameters, characterization methods, and background inventory.
    foreground_db_name : str, optional
        The name of the foreground Brightway database. Defaults to
        `config.foreground_db_name`, which is itself "foreground" unless set.
        Passing a name here overrides the one on the config.
    """
    self.config = config
    if foreground_db_name is None:
        foreground_db_name = config.foreground_db_name
    if foreground_db_name not in bd.databases:
        raise ValueError(
            f"Foreground database '{foreground_db_name}' is not defined."
        )
    self.foreground_db = bd.Database(foreground_db_name)
    self.background_dbs = {}
    if config.temporal.database_dates is not None:
        self.background_dbs = {
            db: date
            for db, date in config.temporal.database_dates.items()
            if db != self.foreground_db.name
        }
    else:
        for db_name in bd.databases:
            db = bd.Database(db_name)
            if (date := db.metadata.get("representative_time")) is not None:
                self.background_dbs[db.name] = datetime.fromisoformat(date)

    self.biosphere_db = bd.Database(bd.config.biosphere)

    self._demand = {}
    self._processes = {}
    self._products = {}  # Maps product codes to product names
    self._intermediate_flows = {}
    self._elementary_flows = {}

    self._reference_products = set()
    self._system_time = set()
    self._process_time = set()
    self._category = set()

    self._foreground_technosphere = {}
    self._internal_demand_technosphere = {}  # (process, product, year) -> amount
    self._foreground_biosphere = {}
    self._foreground_production = {}
    self._background_inventory = {}
    self._mapping = {}
    self._characterization = {}
    self._operation_flow = {}
    self._operation_time_limits = {}

    # Vintage-dependent parameters extracted from exchange attributes
    self._foreground_technosphere_vintages = {}
    self._foreground_biosphere_vintages = {}
    self._foreground_production_vintages = {}
    self._vintage_improvements = {}
    self._reference_vintages = set()

    self._parse_demand()
    self._construct_foreground_tensors()
    self._prepare_background_inventory()
    self._construct_characterization_tensor()
    self._prune_uncharacterized_flows()
    self._construct_mapping_matrix()

Attributes

processes: dict property

Read-only access to the processes dictionary.

intermediate_flows: dict property

Read-only access to the intermediate flows dictionary.

elementary_flows: dict property

Read-only access to the elementary flows dictionary.

reference_products: set property

Read-only access to the functional flows list.

system_time: set property

Read-only access to the system time list.

category: set property

Read-only access to the impact categories list.

process_time: set property

Read-only access to the process time list.

foreground_technosphere: dict property

Read-only access to the foreground technosphere tensor.

foreground_biosphere: dict property

Read-only access to the foreground biosphere tensor.

foreground_production: dict property

Read-only access to the foreground production tensor.

background_inventory: dict property

Read-only access to the inventory tensor.

mapping: dict property

Read-only access to the mapping matrix.

characterization: dict property

Read-only access to the characterization matrix.

demand: dict property

Read-only access to the parsed demand dictionary.

operation_flow: dict property

Read-only access to the operation flow dictionary.

operation_time_limits: dict property

Read-only access to the operation time limits dictionary.

products: dict property

Read-only access to the products dictionary.

internal_demand_technosphere: dict property

Read-only access to the internal demand technosphere tensor.

foreground_technosphere_vintages: Optional[dict] property

Read-only access to vintage-specific technosphere values.

foreground_biosphere_vintages: Optional[dict] property

Read-only access to vintage-specific biosphere values.

foreground_production_vintages: Optional[dict] property

Read-only access to vintage-specific production values.

vintage_improvements: Optional[dict] property

Read-only access to vintage improvement scaling factors.

reference_vintages: Optional[list] property

Read-only access to reference vintage years.

Methods:

parallel_inventory_tensor_calculation(n_jobs: Optional[int] = None) -> None

Compute the background inventory tensor for all background databases in parallel, one process per database.

Each database needs its own technosphere matrix built and factorized, which is the bulk of the work and is independent between databases. Results are merged into the module-level cache of the parent process, so a rerun in the same session is served from memory.

Worker processes are spawned, so a plain script calling this must guard its entry point with if __name__ == "__main__":. Notebooks need no guard.

Parameters:

Name Type Description Default
n_jobs int

Number of worker processes. Defaults to one per background database, capped by the CPU count.

None
Side Effects
- self._background_inventory: Combined inventory tensor for all
  background databases.
- self._elementary_flows: Updated dictionary of all observed elementary
  flows.
Source code in src/optimex/lca_processor.py
def parallel_inventory_tensor_calculation(
    self, n_jobs: Optional[int] = None
) -> None:
    """
    Compute the background inventory tensor for all background databases in
    parallel, one process per database.

    Each database needs its own technosphere matrix built and factorized, which
    is the bulk of the work and is independent between databases. Results are
    merged into the module-level cache of the parent process, so a rerun in the
    same session is served from memory.

    Worker processes are spawned, so a plain script calling this must guard its
    entry point with ``if __name__ == "__main__":``. Notebooks need no guard.

    Parameters
    ----------
    n_jobs : int, optional
        Number of worker processes. Defaults to one per background database,
        capped by the CPU count.

    Side Effects
    ------------
        - self._background_inventory: Combined inventory tensor for all
          background databases.
        - self._elementary_flows: Updated dictionary of all observed elementary
          flows.
    """
    cutoff = self.config.background_inventory.cutoff
    project = bd.projects.current
    biosphere_db_name = self.biosphere_db.name
    base_dirs = (
        str(bd.projects._base_data_dir),
        str(bd.projects._base_logs_dir),
    )

    pending_per_db = {}
    for db_name in self.background_dbs:
        pending = self._pending_flows(db_name, self._intermediate_flows, cutoff)
        if pending:
            pending_per_db[db_name] = pending

    if len(pending_per_db) == 1:
        # A single database gains nothing from a worker process, and staying
        # in-process avoids the spawn requirements entirely.
        db_name, pending = next(iter(pending_per_db.items()))
        entries = compute_db_inventory_entries(
            db_name, pending, cutoff, biosphere_db_name
        )
        cache_token = _cache_token(db_name, cutoff)
        _BACKGROUND_INVENTORY_CACHE.update(
            {cache_token + identity: entry for identity, entry in entries.items()}
        )
        self._store_on_disk(db_name, cutoff, entries)
    elif pending_per_db:
        n_jobs = min(
            n_jobs or len(pending_per_db),
            len(pending_per_db),
            os.cpu_count() or 1,
        )
        logger.info(
            f"Calculating inventories of {len(pending_per_db)} databases "
            f"in {n_jobs} processes."
        )
        with ProcessPoolExecutor(max_workers=n_jobs) as executor:
            futures = {
                executor.submit(
                    compute_db_inventory_entries,
                    db_name,
                    pending,
                    cutoff,
                    biosphere_db_name,
                    project,
                    base_dirs,
                ): db_name
                for db_name, pending in pending_per_db.items()
            }
            for future in as_completed(futures):
                db_name = futures[future]
                cache_token = _cache_token(db_name, cutoff)
                entries = future.result()
                _BACKGROUND_INVENTORY_CACHE.update(
                    {
                        cache_token + identity: entry
                        for identity, entry in entries.items()
                    }
                )
                # Written from the parent so that workers never contend for
                # the same cache file.
                self._store_on_disk(db_name, cutoff, entries)

    for db_name in self.background_dbs:
        inventory_tensor, elementary_flows = _assemble_inventory_tensor(
            db_name, self._intermediate_flows, cutoff
        )
        self._background_inventory.update(inventory_tensor)
        self._elementary_flows.update(elementary_flows)

Functions:

clear_lca_caches(include_disk: bool = False, cache_dir=None) -> None

Clear the module-level background inventory and metadata caches.

Parameters:

Name Type Description Default
include_disk bool

Also delete the on-disk inventory cache of the current project.

False
cache_dir str or Path

Directory of the on-disk cache, if it is not in the default location.

None
Source code in src/optimex/lca_processor.py
def clear_lca_caches(include_disk: bool = False, cache_dir=None) -> None:
    """
    Clear the module-level background inventory and metadata caches.

    Parameters
    ----------
    include_disk : bool, optional
        Also delete the on-disk inventory cache of the current project.
    cache_dir : str or Path, optional
        Directory of the on-disk cache, if it is not in the default location.
    """
    _BACKGROUND_INVENTORY_CACHE.clear()
    _BIOSPHERE_METADATA_CACHE.clear()
    _NODE_INDEX_CACHE.clear()
    _CHARACTERIZATION_FUNCTION_CACHE.clear()

    if include_disk:
        directory = Path(
            cache_dir
            if cache_dir is not None
            else Path(bd.projects.dir) / "optimex-inventory-cache"
        )
        for path in directory.glob("*.pickle"):
            path.unlink(missing_ok=True)

compute_db_inventory_entries(db_name: str, intermediate_flows: dict, cutoff: Optional[float] = None, biosphere_db_name: Optional[str] = None, project: Optional[str] = None, base_dirs: Optional[Tuple[str, str]] = None) -> dict

Compute aggregated background inventories for the given intermediate flows.

All flows are solved against one technosphere matrix, factorized once when there are enough of them to amortize it. For an intermediate flow :math:j with unit demand, the aggregated elementary flow vector is :math:g_j = B x_j, i.e. the column of :math:B A^{-1} belonging to that flow. The per-background-process breakdown that LCA.lci() builds (B times diag(x_j)) is never needed here and is skipped, since only the aggregate enters the optimization.

This is a module-level function so that it can also run in a worker process.

Parameters:

Name Type Description Default
db_name str

Name of the background database to analyze.

required
intermediate_flows dict

Dictionary mapping intermediate flow codes (foreground reference codes) to identity metadata dicts with keys "name", "reference product", and "location".

required
cutoff float

If given, keep only the cutoff largest elementary flows (by absolute amount) per intermediate flow. Default None keeps every non-zero flow, since a small flow can still carry a large characterized impact.

None
biosphere_db_name str

Biosphere database to read flow codes and names from. Defaults to the project's configured biosphere database.

None
project str

Brightway project to activate first. Needed when running in a worker process, which starts without an active project.

None
base_dirs tuple of str

(data directory, logs directory) of the Brightway installation, for worker processes that would otherwise fall back to the default location.

None

Returns:

Type Description
dict

{flow identity: {elementary flow code: (name, amount)}}.

Source code in src/optimex/lca_processor.py
def compute_db_inventory_entries(
    db_name: str,
    intermediate_flows: dict,
    cutoff: Optional[float] = None,
    biosphere_db_name: Optional[str] = None,
    project: Optional[str] = None,
    base_dirs: Optional[Tuple[str, str]] = None,
) -> dict:
    """
    Compute aggregated background inventories for the given intermediate flows.

    All flows are solved against one technosphere matrix, factorized once when
    there are enough of them to amortize it. For an intermediate flow :math:`j` with unit demand, the
    aggregated elementary flow vector is :math:`g_j = B x_j`, i.e. the column of
    :math:`B A^{-1}` belonging to that flow. The per-background-process breakdown
    that `LCA.lci()` builds (B times diag(x_j)) is never needed here and is skipped,
    since only the aggregate enters the optimization.

    This is a module-level function so that it can also run in a worker process.

    Parameters
    ----------
    db_name : str
        Name of the background database to analyze.
    intermediate_flows : dict
        Dictionary mapping intermediate flow codes (foreground reference codes) to
        identity metadata dicts with keys "name", "reference product", and
        "location".
    cutoff : float, optional
        If given, keep only the ``cutoff`` largest elementary flows (by absolute
        amount) per intermediate flow. Default ``None`` keeps every non-zero flow,
        since a small flow can still carry a large characterized impact.
    biosphere_db_name : str, optional
        Biosphere database to read flow codes and names from. Defaults to the
        project's configured biosphere database.
    project : str, optional
        Brightway project to activate first. Needed when running in a worker
        process, which starts without an active project.
    base_dirs : tuple of str, optional
        ``(data directory, logs directory)`` of the Brightway installation, for
        worker processes that would otherwise fall back to the default location.

    Returns
    -------
    dict
        ``{flow identity: {elementary flow code: (name, amount)}}``.
    """
    if base_dirs is not None and str(bd.projects._base_data_dir) != base_dirs[0]:
        bd.projects.change_base_directories(
            Path(base_dirs[0]), Path(base_dirs[1]), project_name=project
        )
    elif project is not None and bd.projects.current != project:
        bd.projects.set_current(project)
    if biosphere_db_name is None:
        biosphere_db_name = bd.config.biosphere

    logger.info(f"Calculating inventory for database: {db_name}")
    db = bd.Database(name=db_name)

    activities = {}
    for key, meta in intermediate_flows.items():
        try:
            if isinstance(meta, dict):
                activities[key] = _resolve_node(db_name, meta)
            else:
                activities[key] = db.get(code=key)
        except Exception as e:  # Catch exceptions (e.g., if activity not found)
            logger.warning(
                f"Failed to resolve intermediate flow {meta!r} (code '{key}') "
                f"in '{db_name}': {e}"
            )

    if not activities:
        return {}

    # No LCIA method is needed: the inventory does not depend on it, and the
    # characterization factors are applied later, per system year.
    lca = bc.LCA({activity: 1 for activity in activities.values()})
    factorize = len(activities) > _FACTORIZE_MIN_FLOWS
    lca.lci(factorize=factorize)
    logger.info(
        f"Built {'and factorized ' if factorize else ''}technosphere matrix "
        f"for: {db_name}"
    )

    bio_meta = _biosphere_metadata(biosphere_db_name)
    reversed_biosphere = lca.dicts.biosphere.reversed
    row_codes = []
    row_names = []
    for row in range(lca.biosphere_matrix.shape[0]):
        flow_id = reversed_biosphere[row]
        if flow_id in bio_meta:
            code, name = bio_meta[flow_id]
        else:
            node = bd.get_node(id=flow_id)
            code, name = node["code"], node["name"]
        row_codes.append(code)
        row_names.append(name)

    entries = {}
    for key, activity in tqdm(activities.items()):
        # `lci()` is bypassed on purpose: it would build the full
        # (elementary flow x background process) inventory matrix, of which only
        # the row sums are used below.
        lca.build_demand_array({activity.id: 1})
        # `reshape(-1)` guards against pypardiso: its `spsolve` squeezes the
        # result, so a single-process background database yields a 0-d array,
        # which sparse `@` rejects as a scalar operand.
        supply = np.asarray(lca.solve_linear_system()).reshape(-1)
        aggregated = lca.biosphere_matrix @ supply

        rows = np.flatnonzero(aggregated)
        if cutoff is not None and len(rows) > int(cutoff):
            largest = np.argpartition(np.abs(aggregated[rows]), -int(cutoff))
            rows = rows[largest[-int(cutoff) :]]

        if not len(rows):
            logger.warning(
                f"Activity {activity} has no non-zero inventory in '{db_name}'."
            )

        entries[_flow_identity(key, intermediate_flows[key])] = {
            row_codes[row]: (row_names[row], float(aggregated[row])) for row in rows
        }

    logger.info(f"Finished calculating inventory for database: {db_name}")
    return entries