Source code for pulpo.utils.bw_parser

import ast
from typing import List, Union, Dict, Any, TypedDict
import bw2calc as bc
import bw2data as bd
from pulpo.utils.utils import get_bw_version, build_bw25_params
from stats_arrays.random import MCRandomNumberGenerator
import numpy as np
from pulpo.utils.warning import warn

[docs] class LCIDataDict(TypedDict): """The LCI data of a worker (``worker.lci_data``): matrices, maps and uncertainty parameters."""
[docs] matrices: Dict[str, np.ndarray]
[docs] intervention_matrix: np.ndarray
[docs] technology_matrix: np.ndarray
[docs] process_map: Dict[Any, Any]
[docs] intervention_params: Any
[docs] characterization_params: Dict[str, Any]
[docs] intervention_map: Dict[Any, Any]
[docs] intervention_map_metadata: Dict[Any, str]
[docs] process_map_metadata: Dict[Any, str]
[docs] def set_project(project: str): # Set project and check if it exists if project not in bd.projects: raise ValueError(f"Project '{project}' does not exist. Please check the project name.") _ensure_project_current(project)
[docs] def _ensure_project_current(project: str): """Activate ``project`` only if it is not already the active bw2data project. ``bd.projects.set_current()`` always tears down and rebuilds bw2data's sqlite3 connections (including a full ``gc.collect()``) even when re-selecting the active project. Skipping that redundant reset matters because this runs once per ``import_data`` call, i.e. once per Monte Carlo iteration. """ if bd.projects.current != project: bd.projects.set_current(project)
[docs] def import_data(project: str, databases: Union[str, List[str]], method: Union[str, List[str], Dict[str, int]], intervention_matrix_name: str, seed: Union[None, int] = None, resample: Union[str, List[str]] = ("A", "B", "Q"), compute_uncertainty_params: bool = True) -> LCIDataDict: """ Main function to import LCI data for a project from one or more databases. Args: project (str): Name of the project. databases (Union[str, List[str]]): One or more databases (e.g. foreground and background); order does not matter. method (Union[str, List[str], Dict[str, int]]): Method(s) for data retrieval. intervention_matrix_name (str): Name of the intervention matrix. seed (Union[None, int], optional): Seed for RNG. If None, the default A, B, and Q matrices are used. compute_uncertainty_params (bool, optional): Whether to assemble the bw25 'intervention_params' / 'characterization_params' structured arrays. These are only needed by the uncertainty sub-package (``uncertainty.import_declared``), not by A/B/Q resampling, so skipping them speeds up repeated Monte Carlo calls. Default True to preserve the full LCIDataDict. An array is ``None``, without a warning, when the databases or the method carry no (complete) uncertainty data: a deterministic study does not need it, and ``import_declared`` says so when uncertainty is actually requested. Returns: Dict[str, Union[dict, Any]]: Dictionary containing imported LCI data. """ set_project(project) # Normalize databases input to a list if isinstance(databases, str): databases = [databases] # Validate each specified database for db in databases: if db not in bd.databases: raise ValueError( f"Database '{db}' does not exist in the project '{project}'. " f"Available databases: {list(bd.databases.keys())}" ) # Prepare methods if isinstance(method, str): method = [method] # Convert single string to list of strings method = sorted(method) # Retrieve and validate methods methods = retrieve_methods(project, method) invalid_methods = [m for m in method if m not in [str(mt) for mt in bd.methods]] if invalid_methods: raise ValueError( f"The following methods do not exist in the project '{project}': {invalid_methods}. " f"Available methods: {[str(mt) for mt in bd.methods]}" ) # Initialize database objects eidbs = [] for database in databases: eidbs.append(bd.Database(database)) # The LCA also loads the databases these link to (ecoinvent behind a foreground, # say); their processes are columns of the matrices as well. activity_rows = _activity_rows(_linked_databases(databases)) bw_version = get_bw_version() dist = seed is not None if isinstance(resample, str): resample = [resample.upper()] resample = [r.upper() for r in resample] match bw_version: case 'bw25': lca, characterization_matrices, characterization_params, process_map, bio_params = \ _load_lci_bw25(eidbs, methods, seed, dist, resample, compute_uncertainty_params, activity_rows) case 'bw2': lca, characterization_matrices, characterization_params, process_map, bio_params = \ _load_lci_bw2(eidbs, methods, seed, dist, resample) # final A & B matrices technology_matrix = lca.technosphere_matrix intervention_matrix = lca.biosphere_matrix # Descriptive labels for every process in the matrices, linked databases included process_map_metadata = { process_map[(db, code)]: f"{name} | {product or ''} | {location or ''}" for _, db, code, name, product, location in activity_rows if (db, code) in process_map } # ATTN: could probbly ask BW what the biosphere matrix is and then move this code into the cases further up if intervention_matrix_name in bd.databases: eidb_bio = bd.Database(intervention_matrix_name) match bw_version: case 'bw25': intervention_map = {act.key: lca.dicts.biosphere[act.id] for act in eidb_bio if act.id in lca.dicts.biosphere} # ATTN: This is adherring to old ways of storring data with keys ... how to work with IDs instead? case 'bw2': intervention_map = lca.biosphere_dict intervention_map_metadata = {} for act in eidb_bio: if act.key in intervention_map: intervention_map_metadata[intervention_map[act.key]] = act['name'] + ' | ' + str(act['categories']) else: raise Exception( "The name of the biosphere is not '" + intervention_matrix_name + "'. Please specify the correct biosphere.") lci_data:LCIDataDict = { 'matrices': characterization_matrices, 'intervention_matrix': intervention_matrix, 'technology_matrix': technology_matrix, 'process_map': process_map, 'intervention_params': bio_params, 'characterization_params': characterization_params, 'intervention_map': intervention_map, 'intervention_map_metadata':intervention_map_metadata, 'process_map_metadata':process_map_metadata, } return lci_data
[docs] def _load_lci_bw25(eidbs, methods, seed, dist, resample, compute_uncertainty_params, activity_rows): """Build the (optionally resampled) A/B/Q matrices and maps for a bw25 project. One LCA with a combined functional unit (one activity per listed database) loads the union of all databases and their dependencies into a single index space, so database order does not matter. Only the matrices in ``resample`` are drawn from their distributions, each from its own random stream (child seeds spawned from ``seed``, as in the bw2 path); the others keep their deterministic values. bw2calc alone would draw every matrix, all from the same seed. Returns ``(lca, characterization_matrices, characterization_params, process_map, intervention_params)``; ``intervention_params`` is ``None`` when not requested or unavailable. """ characterization_matrices = {} characterization_params = {} draw = {name: dist and letter in resample for letter, name in (('A', 'technosphere_matrix'), ('B', 'biosphere_matrix'), ('Q', 'characterization_matrix'))} if dist: child_seeds = [int(c.generate_state(1)[0]) for c in np.random.SeedSequence(seed).spawn(2 + len(methods))] tech_seed, bio_seed = child_seeds[:2] method_seeds = {str(mth): s for mth, s in zip(methods, child_seeds[2:])} # Build technosphere/biosphere matrices ONCE for all databases (heavy step) demand = {eidb.random(): 1 for eidb in eidbs} fu, data_objs, _ = bd.prepare_lca_inputs(demand, method=methods[0]) lca = bc.LCA(demand=fu, data_objs=data_objs, use_distributions=False, selective_use={name: {'use_distributions': use} for name, use in draw.items()}, seed_override=tech_seed if dist else None) lca.load_lci_data() if draw['biosphere_matrix']: # bw2calc draws B with A's seed; redraw it from a stream of its own. import matrix_utils as mu lca.biosphere_mm = mu.MappedMatrix( packages=lca.packages, matrix='biosphere_matrix', use_arrays=lca.check_selective_use('biosphere_matrix')[0], use_distributions=True, seed_override=bio_seed, row_mapper=lca.biosphere_mm.row_mapper, col_mapper=lca.technosphere_mm.col_mapper, empty_ok=True) lca.biosphere_matrix = lca.biosphere_mm.matrix for method in methods: m = str(method) if dist: lca.seed_override = method_seeds[m] # the Q of each method from its own stream lca.switch_method(method) # cheap: swaps only the characterization datapackage/matrix if compute_uncertainty_params: cf_params, _ = build_bw25_params( lca.packages, 'characterization_matrix', lca.dicts.biosphere ) characterization_params[m] = cf_params characterization_matrices[m] = lca.characterization_matrix # Method-independent biosphere uncertainty params: data_objs spans all listed # databases (and is unaffected by switch_method), so one call covers everything. if compute_uncertainty_params: intervention_params, _ = build_bw25_params( data_objs, 'biosphere_matrix', lca.dicts.biosphere, lca.dicts.product ) else: intervention_params = None # Every process the LCA loaded, also those of linked databases that were not listed. process_map = {(db, code): lca.dicts.product[i] for i, db, code, *_ in activity_rows if i in lca.dicts.product} return lca, characterization_matrices, characterization_params, process_map, intervention_params
[docs] def _load_lci_bw2(eidbs, methods, seed, dist, resample): """Build the (optionally resampled) A/B/Q matrices and maps for a legacy bw2 project. One LCA with a combined functional unit (one activity per listed database) loads the union of all databases and their dependencies into a single index space, so database order does not matter. A, B and each method's Q are resampled from independent child seeds spawned from ``seed``: a shared seed would give every MCRandomNumberGenerator the identical percentile stream and inject spurious cross-matrix correlation (bw25 likewise draws each matrix independently). Returns ``(lca, characterization_matrices, characterization_params, process_map, intervention_params)``. """ if dist: child_seeds = np.random.SeedSequence(seed).spawn(2 + len(methods)) tech_seed = int(child_seeds[0].generate_state(1)[0]) bio_seed = int(child_seeds[1].generate_state(1)[0]) method_cf_seeds = { str(mth): int(child_seeds[2 + i].generate_state(1)[0]) for i, mth in enumerate(methods) } characterization_matrices = {} characterization_params = {} # Build technosphere/biosphere matrices ONCE for all databases (heavy step) demand = {eidb.random(): 1 for eidb in eidbs} lca = bc.LCA(demand, methods[0]) lca.load_lci_data() for method in methods: lca.switch_method(method) # cheap: swaps only the characterization factors m = str(method) characterization_params[m] = lca.cf_params if dist and "Q" in resample: rng = MCRandomNumberGenerator(lca.cf_params, seed=method_cf_seeds[m]) lca.rebuild_characterization_matrix(rng.next()) characterization_matrices[m] = lca.characterization_matrix process_map = dict(lca.product_dict) tech_params, bio_params = lca.tech_params, lca.bio_params if dist and "A" in resample: lca.rebuild_technosphere_matrix(MCRandomNumberGenerator(tech_params, seed=tech_seed).next()) if dist and "B" in resample: lca.rebuild_biosphere_matrix(MCRandomNumberGenerator(bio_params, seed=bio_seed).next()) return lca, characterization_matrices, characterization_params, process_map, bio_params
[docs] def _linked_databases(names): """``names`` and every database they link to, directly or not (``depends``).""" seen, stack = [], list(names) while stack: name = stack.pop() if name in seen or name not in bd.databases: continue seen.append(name) stack.extend(bd.databases[name].get('depends', [])) return seen
[docs] def _activity_rows(db_names): """``(id, database, code, name, reference product, location)`` of every activity of ``db_names``: one SQL query where the backend allows it, else by iteration.""" ActivityDataset, _ = _activity_orm() in_sql = [db for db in db_names if ActivityDataset is not None and bd.databases[db].get('backend', 'sqlite') in _SQL_BACKENDS] rows = [] if in_sql: A = ActivityDataset rows += list(A.select(A.id, A.database, A.code, A.name, A.product, A.location) .where(A.database.in_(in_sql)).tuples()) for db in db_names: if db not in in_sql: rows += [(act.id, act['database'], act['code'], act.get('name'), act.get('reference product'), act.get('location')) for act in bd.Database(db)] return rows
[docs] def _activity_orm(): """Return (ActivityDataset, Activity) ORM handles for the installed bw2data version. Both bw2 and bw25 store activities in a SQLite ``activitydataset`` table whose ``name``, ``product`` (reference product) and ``location`` are real columns, so exact-match filters can run server-side instead of loading every activity proxy. Returns ``(None, None)`` if the ORM is unavailable (unexpected backend/version). """ try: # bw25 from bw2data.backends import ActivityDataset, Activity return ActivityDataset, Activity except ImportError: pass try: # bw2 (peewee backend) from bw2data.backends.peewee.schema import ActivityDataset from bw2data.backends.peewee.proxies import Activity return ActivityDataset, Activity except ImportError: return None, None
# Backends whose activities live in the SQLite activitydataset table and can be # queried server-side. Anything else falls back to iterating the database.
[docs] _SQL_BACKENDS = {'sqlite', 'iotable'}
[docs] def _query_processes_sql(ActivityDataset, Activity, db_name, keys, activities, reference_products, locations): """Fetch matching activities of one database with a single SQL query. Only the matching rows are materialized into ``Activity`` proxies, instead of unpickling the whole database as the iteration fallback does. """ query = ActivityDataset.select().where(ActivityDataset.database == db_name) if keys is not None: codes = [code for key_db, code in keys if key_db == db_name] if not codes: return [] query = query.where(ActivityDataset.code.in_(codes)) else: if activities is not None: query = query.where(ActivityDataset.name.in_(activities)) if reference_products is not None: query = query.where(ActivityDataset.product.in_(reference_products)) if locations is not None: query = query.where(ActivityDataset.location.in_(locations)) return [Activity(document) for document in query]
[docs] def _iterate_processes(db_name, keys, activities, reference_products, locations): """Legacy fallback: load every activity of the database and filter in Python.""" eidb = bd.Database(db_name) if keys is not None: keys_set = set(keys) return [proc for proc in eidb if proc.key in keys_set] activity_set = set(activities) if activities is not None else None reference_product_set = set(reference_products) if reference_products is not None else None location_set = set(locations) if locations is not None else None return [ proc for proc in eidb if (activity_set is None or proc['name'] in activity_set) and (reference_product_set is None or proc.get('reference product') in reference_product_set) and (location_set is None or proc['location'] in location_set) ]
[docs] def retrieve_processes(project: str, databases: Union[str, List[str]], keys=None, activities=None, reference_products=None, locations=None): """ Retrieve activities from one or more databases based on specified keys, activities, reference products, and locations. Filters are matched exactly and combined with AND; ``keys`` takes precedence over the other filters. Uses a server-side SQL query where possible (SQLite-backed databases) and falls back to iterating the database otherwise. Args: project (str): Name of the project. databases (Union[str, List[str]]): Name of the primary database or a list of databases (foreground, background). keys (list, optional): List of keys to filter activities, as "('db', 'code')" strings or (db, code) tuples. activities (list, optional): List of activity names to filter. reference_products (list, optional): List of reference products to filter. locations (list, optional): List of locations to filter. Returns: list: List of matching activities from the specified databases. """ _ensure_project_current(project) # Normalize inputs to lists if isinstance(databases, str): databases = [databases] if activities is not None and not isinstance(activities, list): activities = [activities] if reference_products is not None and not isinstance(reference_products, list): reference_products = [reference_products] if locations is not None and not isinstance(locations, list): locations = [locations] if keys is not None: if isinstance(keys, str): keys = [keys] keys = [key if isinstance(key, tuple) else ast.literal_eval(key) for key in keys] ActivityDataset, Activity = _activity_orm() matching_processes = [] for db_name in databases: use_sql = ( ActivityDataset is not None and bd.databases.get(db_name, {}).get('backend', 'sqlite') in _SQL_BACKENDS ) if use_sql: matching_processes.extend(_query_processes_sql( ActivityDataset, Activity, db_name, keys, activities, reference_products, locations )) else: matching_processes.extend(_iterate_processes( db_name, keys, activities, reference_products, locations )) if not matching_processes: print("No activities match the given specifications or the input format is incorrect.") return matching_processes
[docs] def retrieve_env_interventions(project: str = '', intervention_matrix: str = 'biosphere3', keys=None, activities=None, categories=None): """ Retrieve environmental interventions from the biosphere database based on specified keys, activities, and categories. Filters are matched exactly and combined with AND; ``keys`` takes precedence over the other filters. Each filter takes one value or a list of them. Categories are tuples such as ``('air', 'urban air close to ground')``. Args: project (str, optional): Name of the project. intervention_matrix (str): Name of the intervention matrix. keys (optional): Flow keys, as (database, code) tuples or their string form. activities (optional): Flow names. categories (optional): Flow categories, as tuples or their string form. Returns: list: The matching environmental flows; empty, with a warning, if none match. """ # Set project and get database _ensure_project_current(project) eidb = bd.Database(intervention_matrix) if keys is not None: keys = {_parse_tuple(key, 'keys', "('biosphere3', 'code')") for key in _as_list(keys)} matching_flows = [flow for flow in eidb if flow.key in keys] else: names = None if activities is None else set(_as_list(activities)) if categories is not None: categories = {_parse_tuple(category, 'categories', "('air', 'urban air close to ground')") for category in _as_list(categories)} matching_flows = [flow for flow in eidb if (names is None or flow['name'] in names) and (categories is None or tuple(flow.get('categories') or ()) in categories)] if not matching_flows: warn(f"No flows in {intervention_matrix!r} match keys={keys!r}, activities={activities!r}, " f"categories={categories!r}; names, keys and categories are matched exactly.") return matching_flows
[docs] def _as_list(value): """A filter value as a list: a string or a tuple is one value.""" return [value] if isinstance(value, (str, tuple)) else list(value)
[docs] def _parse_tuple(value, name, example): """A key or category given as a tuple or its string form, as a tuple.""" parsed = value if isinstance(value, str): try: parsed = ast.literal_eval(value) except (ValueError, SyntaxError): pass if not isinstance(parsed, (tuple, list)): raise ValueError(f"{name}: expected tuples such as {example} or their string form, got {value!r}.") return tuple(parsed)
[docs] def retrieve_methods(project: str, sub_string: List[str]) -> List[str]: """ Retrieve all methods that contain the specified list of substrings. Args: project (str): Name of the project. sub_string (List[str]): List of substrings to search for in method names. Returns: List[str]: List of methods that match the substrings. """ _ensure_project_current(project) return [method for method in bd.methods if any([x.lower() in str(method).lower() for x in sub_string])]