diff --git a/activity_browser/app/bwutils/__init__.py b/activity_browser/app/bwutils/__init__.py index 230771a7a..45c1e5e6f 100644 --- a/activity_browser/app/bwutils/__init__.py +++ b/activity_browser/app/bwutils/__init__.py @@ -5,7 +5,6 @@ """ import brightway2 as bw from .metadata import AB_metadata -from .montecarlo import CSMonteCarloLCA from .multilca import MLCA, Contributions from .pedigree import PedigreeMatrix from .presamples import PresamplesContributions, PresamplesMLCA @@ -14,7 +13,8 @@ CFUncertaintyInterface, ExchangeUncertaintyInterface, ParameterUncertaintyInterface, get_uncertainty_interface ) - +from .montecarlo import MonteCarloLCA +from .sensitivity_analysis import GlobalSensitivityAnalysis def cleanup(): n_dir = bw.projects.purge_deleted_directories() diff --git a/activity_browser/app/bwutils/commontasks.py b/activity_browser/app/bwutils/commontasks.py index d37fc448d..ecfed7215 100644 --- a/activity_browser/app/bwutils/commontasks.py +++ b/activity_browser/app/bwutils/commontasks.py @@ -1,12 +1,13 @@ # -*- coding: utf-8 -*- import hashlib - +import os import textwrap import arrow import brightway2 as bw from bw2data import databases from bw2data.proxies import ActivityProxyBase from bw2data.utils import natural_sort +from bw2data.project import ProjectDataset, SubstitutableDatabase """ bwutils is a collection of methods that build upon brightway2 and are generic enough to provide here so that we avoid @@ -56,6 +57,29 @@ def format_activity_label(key, style='pnl', max_length=40): return wrap_text(str(key)) return wrap_text(label, max_length=max_length) +# Switch brightway directory +def switch_brightway2_dir(dirpath): + if dirpath == bw.projects._base_data_dir: + print('dirpath already loaded') + return False + try: + assert os.path.isdir(dirpath) + bw.projects._base_data_dir = dirpath + bw.projects._base_logs_dir = os.path.join(dirpath, "logs") + # create folder if it does not yet exist + if not os.path.isdir(bw.projects._base_logs_dir): + os.mkdir(bw.projects._base_logs_dir) + # load new brightway directory + bw.projects.db = SubstitutableDatabase( + os.path.join(bw.projects._base_data_dir, "projects.db"), + [ProjectDataset] + ) + print('Loaded brightway2 data directory: {}'.format(bw.projects._base_data_dir)) + return True + + except AssertionError: + print('Could not access BW_DIR as specified in settings.py') + return False # Database def get_database_metadata(name): diff --git a/activity_browser/app/bwutils/manager.py b/activity_browser/app/bwutils/manager.py index 3d7d3551a..142a0de81 100644 --- a/activity_browser/app/bwutils/manager.py +++ b/activity_browser/app/bwutils/manager.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- from abc import abstractmethod +from collections import defaultdict from collections.abc import Iterator import itertools from typing import Iterable, List, Optional, Tuple from asteval import Interpreter +from bw2calc import LCA from bw2data.backends.peewee import ExchangeDataset from bw2data.parameters import ( ProjectParameter, DatabaseParameter, ActivityParameter, @@ -166,6 +168,69 @@ def has_parameterized_exchanges() -> bool: """ return ParameterizedExchange.select().exists() + def parameter_exchange_dependencies(self) -> dict: + """ + + Schema: {param1: List[tuple], param2: List[tuple]} + """ + parameters = defaultdict(list) + for act in self.initial.act_by_group_db: + exchanges = self.initial.exc_by_group(act.group) + for exc, formula in exchanges.items(): + params = get_new_symbols([formula]) + # Convert exchange from int to Index + exc = Index.build_from_exchange(ExchangeDataset.get_by_id(exc)) + for param in params: + parameters[param].append(exc) + return parameters + + def extract_active_parameters(self, lca: LCA) -> dict: + """Given an LCA object, extract the used exchanges and build a + dictionary of parameters with the exchanges that use these parameters. + + Schema: {param1: {"name": str, "act": Optional[tuple], + "group": str, "exchanges": List[tuple]}} + """ + params = self.parameter_exchange_dependencies() + used_exchanges = set(lca.biosphere_dict.keys()) + used_exchanges = used_exchanges.union(lca.activity_dict.keys()) + + # Make a pass through the 'params' dictionary and remove exchanges + # that don't exist in the 'used_exchanges' set. + for p in params: + keep = [ + i for i, exc in enumerate(params[p]) + if (exc.input in used_exchanges and exc.output in used_exchanges) + ] + params[p] = [params[p][i] for i in keep] + # Now only keep parameters with exchanges. + keep = [p for p, excs in params.items() if len(excs) > 0] + schema = { + p: {"name": p, "act": None, "group": None, "exchanges": params[p]} + for p in keep + } + # Now start filling in the parameters that remain with information. + for group in self.initial.groups: + for name, data in self.initial.act_by_group(group).items(): + key = (data.get("database"), data.get("code")) + if name in schema: + # Make sure that the activity parameter matches the + # output of the exchange. + exc = next(e.output for e in schema[name]["exchanges"]) + if key == exc: + schema[name]["name"] = name + schema[name]["group"] = group + schema[name]["act"] = key + # Lastly, determine for the remaining parameters if they are database + # or project parameters. + for db in self.initial.databases: + for name, data in self.initial.by_database(db).items(): + if name in schema and schema[name]["group"] is None: + schema[name]["group"] = db + for name in (n for n in schema if schema[n]["group"] is None): + schema[name]["group"] = "project" + return schema + class MonteCarloParameterManager(ParameterManager, Iterator): """Use to sample the uncertainty of parameter values, mostly for use in @@ -226,3 +291,16 @@ def next(self) -> np.ndarray: self.parameters.update(values) data = self.calculate() return self.indices.mock_params(data) + + def retrieve_sampled_values(self, data: dict): + """Enters the sampled values into the 'exchanges' list in the 'data' + dictionary. + """ + for name, vals in data.items(): + param = next((p for p in self.parameters + if p.name == vals.get("name") + and p.group == vals.get("group")), + None) + if param is None: + continue + data[name]["values"].append(param.amount) diff --git a/activity_browser/app/bwutils/montecarlo.py b/activity_browser/app/bwutils/montecarlo.py index 3f27c66aa..fdaddcc9f 100644 --- a/activity_browser/app/bwutils/montecarlo.py +++ b/activity_browser/app/bwutils/montecarlo.py @@ -7,11 +7,12 @@ import numpy as np import pandas as pd from stats_arrays import MCRandomNumberGenerator +from collections import defaultdict from .manager import MonteCarloParameterManager -class CSMonteCarloLCA(object): +class MonteCarloLCA(object): """A Monte Carlo LCA for multiple functional units and methods loaded from a calculation setup.""" def __init__(self, cs_name): if cs_name not in bw.calculation_setups: @@ -20,7 +21,7 @@ def __init__(self, cs_name): ) self.cs_name = cs_name - cs = bw.calculation_setups[cs_name] + self.cs = bw.calculation_setups[cs_name] self.seed = None self.cf_rngs = {} self.CF_rng_vectors = {} @@ -29,26 +30,29 @@ def __init__(self, cs_name): self.include_cfs = True self.include_parameters = True self.param_rng = None - self.param_cols = ["input", "output", "type"] + self.param_cols = ["row", "col", "type"] self.tech_rng: Optional[Union[MCRandomNumberGenerator, np.ndarray]] = None self.bio_rng: Optional[Union[MCRandomNumberGenerator, np.ndarray]] = None self.cf_rng: Optional[Union[MCRandomNumberGenerator, np.ndarray]] = None # functional units - self.func_units = cs['inv'] + self.func_units = self.cs['inv'] self.rev_fu_index = {i: fu for i, fu in enumerate(self.func_units)} # activities self.activity_keys = [list(fu.keys())[0] for fu in self.func_units] self.activity_index = {key: index for index, key in enumerate(self.activity_keys)} - self.rev_activity_index = {v: k for k, v in self.activity_keys} + self.rev_activity_index = {index: key for index, key in enumerate(self.activity_keys)} + # previously: self.rev_activity_index = {v: k for k, v in self.activity_keys} # self.fu_index = {k: i for i, k in enumerate(self.activity_keys)} # methods - self.methods = cs['ia'] + self.methods = self.cs['ia'] self.method_index = {m: i for i, m in enumerate(self.methods)} - self.rev_method_index = {v: k for k, v in self.method_index.items()} + self.rev_method_index = {i: m for i, m in enumerate(self.methods)} + # previously: self.rev_method_index = {v: k for k, v in self.method_index.items()} + # self.rev_method_index = {v: k for k, v in self.method_index.items()} # todo: get rid of the below self.func_unit_translation_dict = {str(bw.get_activity(list(func_unit.keys())[0])): func_unit @@ -56,10 +60,44 @@ def __init__(self, cs_name): self.func_key_dict = {m: i for i, m in enumerate(self.func_unit_translation_dict.keys())} self.func_key_list = list(self.func_key_dict.keys()) - self.results = [] + # GSA calculation variables + self.A_matrices = list() + self.B_matrices = list() + self.CF_dict = defaultdict(list) + self.parameter_exchanges = list() + self.parameters = list() + self.parameter_data = defaultdict(dict) + + self.results = list() self.lca = bw.LCA(demand=self.func_units_dict, method=self.methods[0]) + def unify_param_exchanges(self, data: np.ndarray) -> np.ndarray: + """Convert an array of parameterized exchanges from input/output keys + into row/col values using dicts generated in bw.LCA object. + + If any given exchange does not exist in the current LCA matrix, + it will be dropped from the returned array. + """ + def key_to_rowcol(x) -> Optional[tuple]: + if x["type"] in [0, 1]: + row = self.lca.activity_dict.get(x["input"], None) + col = self.lca.product_dict.get(x["output"], None) + else: + row = self.lca.biosphere_dict.get(x["input"], None) + col = self.lca.activity_dict.get(x["output"], None) + # if either the row or the column is None, return np.NaN. + if row is None or col is None: + return None + return row, col, x["type"], x["amount"] + + # Convert the data and store in a new array, dropping Nones. + converted = (key_to_rowcol(d) for d in data) + unified = np.array([x for x in converted if x is not None], dtype=[ + ('row', ' None: """Constructs the random number generators for all of the matrices that can be altered by uncertainty. @@ -86,11 +124,14 @@ def load_data(self) -> None: if self.include_parameters: self.param_rng = MonteCarloParameterManager(seed=self.seed) + self.lca.activity_dict_rev, self.lca.product_dict_rev, self.lca.biosphere_dict_rev = self.lca.reverse_dict() + def calculate(self, iterations=10, seed: int = None, **kwargs): """Main calculate method for the MC LCA class, allows fine-grained control over which uncertainties are included when running MC sampling. """ start = time() + self.iterations = iterations self.seed = seed or get_seed() self.include_technosphere = kwargs.get("technosphere", True) self.include_biosphere = kwargs.get("biosphere", True) @@ -98,23 +139,68 @@ def calculate(self, iterations=10, seed: int = None, **kwargs): self.include_parameters = kwargs.get("parameters", True) self.load_data() + self.results = np.zeros((iterations, len(self.func_units), len(self.methods))) + # Reset GSA variables to empty. + self.A_matrices = list() + self.B_matrices = list() + self.CF_dict = defaultdict(list) + self.parameter_exchanges = list() + self.parameters = list() + + # Prepare GSA parameter schema: + if self.include_parameters: + self.parameter_data = self.param_rng.extract_active_parameters(self.lca) + # Add a values field to handle all the sampled parameter values. + for k in self.parameter_data: + self.parameter_data[k]["values"] = [] + for iteration in range(iterations): tech_vector = self.tech_rng.next() if self.include_technosphere else self.tech_rng bio_vector = self.bio_rng.next() if self.include_biosphere else self.bio_rng if self.include_parameters: - param_exchanges = self.param_rng.next() - # combination of 'input', 'output', 'type' columns is unique - # For each recalculated exchange, match it to either matrix and - # override the value within that matrix. - for p in param_exchanges: - tech_vector[self.lca.tech_params[self.param_cols] == p[self.param_cols]] = p["amount"] - bio_vector[self.lca.bio_params[self.param_cols] == p[self.param_cols]] = p["amount"] + # Convert the input/output keys into row/col keys, and then match them against + # the tech_ and bio_params + data = self.param_rng.next() + param_exchanges = self.unify_param_exchanges(data) + + # Select technosphere subset from param_exchanges. + subset = param_exchanges[np.isin(param_exchanges["type"], [0, 1])] + # Create index of where to insert new values from tech_params array. + idx = np.argwhere( + np.isin(self.lca.tech_params[self.param_cols], subset[self.param_cols]) + ).flatten() + # Construct unique array of row+col combinations + uniq = np.unique(self.lca.tech_params[idx][["row", "col"]]) + # Use the unique array to sort the subset (ensures values + # are inserted at the correct index) + sort_idx = np.searchsorted(uniq, subset[["row", "col"]]) + # Finally, insert the sorted subset amounts into the tech_vector + # at the correct indexes. + tech_vector[idx] = subset[sort_idx]["amount"] + # Repeat the above, but for the biosphere array. + subset = param_exchanges[param_exchanges["type"] == 2] + idx = np.argwhere( + np.isin(self.lca.bio_params[self.param_cols], subset[self.param_cols]) + ).flatten() + uniq = np.unique(self.lca.bio_params[idx][["row", "col"]]) + sort_idx = np.searchsorted(uniq, subset[["row", "col"]]) + bio_vector[idx] = subset[sort_idx]["amount"] + + # Store parameter data for GSA + self.parameter_exchanges.append(param_exchanges) + self.parameters.append(self.param_rng.parameters.to_gsa()) + # Extract sampled values for parameters, store. + self.param_rng.retrieve_sampled_values(self.parameter_data) self.lca.rebuild_technosphere_matrix(tech_vector) self.lca.rebuild_biosphere_matrix(bio_vector) + # store matrices for GSA + self.A_matrices.append(self.lca.technosphere_matrix) + self.B_matrices.append(self.lca.biosphere_matrix) + if not hasattr(self.lca, "demand_array"): self.lca.build_demand_array() self.lca.lci_calculation() @@ -123,8 +209,8 @@ def calculate(self, iterations=10, seed: int = None, **kwargs): cf_vectors = {} for m in self.methods: cf_vectors[m] = self.cf_rngs[m].next() if self.include_cfs else self.cf_rngs[m] - - # lca_scores = np.zeros((len(self.func_units), len(self.methods))) + # store CFs for GSA (in a list defaultdict) + self.CF_dict[m].append(cf_vectors[m]) # iterate over FUs for row, func_unit in self.rev_fu_index.items(): @@ -135,11 +221,13 @@ def calculate(self, iterations=10, seed: int = None, **kwargs): self.lca.switch_method(m) self.lca.rebuild_characterization_matrix(cf_vectors[m]) self.lca.lcia_calculation() - # lca_scores[row, col] = self.lca.score self.results[iteration, row, col] = self.lca.score - print('CSMonteCarloLCA: finished {} iterations for {} functional units and {} methods in {} seconds.'.format( - iterations, len(self.func_units), len(self.methods), time() - start + print('Monte Carlo LCA: finished {} iterations for {} functional units and {} methods in {} seconds.'.format( + iterations, + len(self.func_units), + len(self.methods), + np.round(time() - start, 2) )) @property @@ -154,6 +242,11 @@ def get_results_by(self, act_key=None, method=None): - if a functional unit and method is provided, results will be given for all runs of that combination - if nothing is given, all results are returned """ + + if not self.results.any(): + raise ValueError('You need to perform a Monte Carlo Simulation first.') + return None + if act_key: act_index = self.activity_index.get(act_key) print('Activity key provided:', act_key, act_index) @@ -179,6 +272,11 @@ def get_results_dataframe(self, act_key=None, method=None, labelled=True): If labelled=True, then the activity keys are converted to a human readable format. """ + + if not self.results.any(): + raise ValueError('You need to perform a Monte Carlo Simulation first.') + return None + if act_key and method or not act_key and not method: raise ValueError('Must provide activity key or method, but not both.') data = self.get_results_by(act_key=act_key, method=method) @@ -211,24 +309,30 @@ def get_labels(key_list, fields: list = None, separator=' | ', return translated_keys -if __name__ == "__main__": - print(bw.projects) - bw.projects.set_current('default') - print(bw.databases) +def perform_MonteCarlo_LCA(project='default', cs_name=None, iterations=10): + """Performs Monte Carlo LCA based on a calculation setup and returns the + Monte Carlo LCA object.""" + print('-- Monte Carlo LCA --\n Project:', project, 'CS:', cs_name) + bw.projects.set_current(project) + + # perform Monte Carlo simulation + mc = MonteCarloLCA(cs_name) + mc.calculate(iterations=iterations) + return mc - cs = bw.calculation_setups['A'] - mc = CSMonteCarloLCA('A') - mc.calculate(iterations=5) + +if __name__ == "__main__": + mc = perform_MonteCarlo_LCA(project='ei34', cs_name='kraft paper', iterations=15) # test the get_results_by() method - print('Testing the get_results_by() method') + print('\nTesting the get_results_by() method') act_key = mc.activity_keys[0] method = mc.methods[0] print(mc.get_results_by(act_key=act_key, method=method)) - print(mc.get_results_by(act_key=act_key, method=None)) - print(mc.get_results_by(act_key=None, method=method)) - print(mc.get_results_by(act_key=None, method=None)) + # print(mc.get_results_by(act_key=act_key, method=None)) + # print(mc.get_results_by(act_key=None, method=method)) + # print(mc.get_results_by(act_key=None, method=None)) # testing the dataframe output print(mc.get_results_dataframe(method=mc.methods[0])) - print(mc.get_results_dataframe(act_key=mc.activity_keys[0])) + print(mc.get_results_dataframe(act_key=mc.activity_keys[0])) \ No newline at end of file diff --git a/activity_browser/app/bwutils/sensitivity_analysis.py b/activity_browser/app/bwutils/sensitivity_analysis.py new file mode 100644 index 000000000..84a1c3f70 --- /dev/null +++ b/activity_browser/app/bwutils/sensitivity_analysis.py @@ -0,0 +1,417 @@ +# -*- coding: utf-8 -*- + +# ============================================================================= +# Global Sensitivity Analysis (GSA) functions and class for the Delta +# Moment-Independent measure based on Monte Carlo simulation LCA results. +# see: https://salib.readthedocs.io/en/latest/api.html#delta-moment-independent-measure +# ============================================================================= + +import brightway2 as bw +import numpy as np +import pandas as pd +from time import time +import traceback +from SALib.analyze import delta +import os + +from .montecarlo import MonteCarloLCA, perform_MonteCarlo_LCA +from ..settings import ab_settings + + +def get_lca(fu, method): + """Calculates a non-stochastic LCA and returns a the LCA object.""" + lca = bw.LCA(fu, method=method) + lca.lci() + lca.lcia() + print('Non-stochastic LCA score:', lca.score) + + # add reverse dictionaries + lca.activity_dict_rev, lca.product_dict_rev, lca.biosphere_dict_rev = lca.reverse_dict() + + return lca + + +def filter_technosphere_exchanges(fu, method, cutoff=0.05, max_calc=1e4): + """Use brightway's GraphTraversal to identify the relevant + technosphere exchanges in a non-stochastic LCA.""" + start = time() + res = bw.GraphTraversal().calculate(fu, method, cutoff=cutoff, max_calc=max_calc) + + # get all edges + technosphere_exchange_indices = [] + for e in res['edges']: + if e['to'] != -1: # filter out head introduced in graph traversal + technosphere_exchange_indices.append((e['from'], e['to'])) + print('TECHNOSPHERE {} filtering resulted in {} of {} exchanges and took {} iterations in {} seconds.'.format( + res['lca'].technosphere_matrix.shape, + len(technosphere_exchange_indices), + res['lca'].technosphere_matrix.getnnz(), + res['counter'], + np.round(time() - start, 2), + )) + return technosphere_exchange_indices + + +def filter_biosphere_exchanges(lca, cutoff=0.005): + """Reduce biosphere exchanges to those that matter for a given impact + category in a non-stochastic LCA.""" + start = time() + + # print('LCA score:', lca.score) + inv = lca.characterized_inventory + # print('Characterized inventory:', inv.shape, inv.nnz) + finv = inv.multiply(abs(inv) > abs(lca.score/(1/cutoff))) + # print('Filtered characterized inventory:', finv.shape, finv.nnz) + biosphere_exchange_indices = list(zip(*finv.nonzero())) + # print(biosphere_indices[:2]) + explained_fraction = finv.sum() / lca.score + # print('Explained fraction of LCA score:', explained_fraction) + print('BIOSPHERE {} filtering resulted in {} of {} exchanges ({}% of total impact) and took {} seconds.'.format( + inv.shape, + finv.nnz, + inv.nnz, + np.round(explained_fraction * 100, 2), + np.round(time() - start, 2), + )) + return biosphere_exchange_indices + + +def get_exchanges(lca, indices, biosphere=False, only_uncertain=True): + """Get actual exchange objects from indices. + By default get only exchanges that have uncertainties. + + Returns + ------- + exchanges : list + List of exchange objects + indices : list of tuples + List of indices + """ + exchanges = list() + for i in indices: + if biosphere: + from_act = bw.get_activity(lca.biosphere_dict_rev[i[0]]) + else: # technosphere + from_act = bw.get_activity(lca.activity_dict_rev[i[0]]) + to_act = bw.get_activity(lca.activity_dict_rev[i[1]]) + + for exc in to_act.exchanges(): + if exc.input == from_act.key: + exchanges.append(exc) + # continue # if there was always only one max exchange between two activities + + # in theory there should be as many exchanges as indices, but since + # multiple exchanges are possible between two activities, the number of + # exchanges must be at least equal or higher to the number of indices + if len(exchanges) < len(indices): # must have at least as many exchanges as indices (assu) + raise ValueError('Error: mismatch between indices provided ({}) and Exchanges received ({}).'.format( + len(indices), len(exchanges) + )) + + # by default drop exchanges and indices if the have no uncertainties + if only_uncertain: + exchanges, indices = drop_no_uncertainty_exchanges(exchanges, indices) + + return exchanges, indices + + +def drop_no_uncertainty_exchanges(excs, indices): + excs_no = list() + indices_no = list() + for exc, ind in zip(excs, indices): + if exc.get('uncertainty type') != 0: + excs_no.append(exc) + indices_no.append(ind) + print('Dropping {} exchanges of {} with no uncertainty. {} remaining.'.format( + len(excs) - len(excs_no), len(excs), len(excs_no) + )) + return excs_no, indices_no + + +def get_exchanges_dataframe(exchanges, indices, biosphere=False): + """Returns a Dataframe from the exchange data and a bit of additional information.""" + + for exc, i in zip(exchanges, indices): + from_act = bw.get_activity(exc.get('input')) + to_act = bw.get_activity(exc.get('output')) + + exc.update( + { + 'index': i, + 'from name': from_act.get('name', np.nan), + 'from location': from_act.get('location', np.nan), + 'to name': to_act.get('name', np.nan), + 'to location': to_act.get('location', np.nan), + } + ) + + # GSA name (needs to yield unique labels!) + if biosphere: + exc.update({ + 'GSA name': "B: {} // {} ({}) [{}]".format( + from_act.get('name', ''), + to_act.get('name', ''), + to_act.get('reference product', ''), + to_act.get('location', ''), + ) + }) + else: + exc.update({ + 'GSA name': "T: {} FROM {} [{}] TO {} ({}) [{}]".format( + from_act.get('reference product', ''), + from_act.get('name', ''), + from_act.get('location', ''), + to_act.get('name', ''), + to_act.get('reference product', ''), + to_act.get('location', ''), + ) + }) + + return pd.DataFrame(exchanges) + + +def get_CF_dataframe(lca, only_uncertain_CFs=True): + """Returns a dataframe with the metadata for the characterization factors + (in the biosphere matrix). Filters non-stochastic CFs if desired (default).""" + data = dict() + for params_index, row in enumerate(lca.cf_params): + if only_uncertain_CFs and row['uncertainty_type'] == 0: + continue + cf_index = row['row'] + bio_act = bw.get_activity(lca.biosphere_dict_rev[cf_index]) + + data.update( + { + params_index: bio_act.as_dict() + } + ) + + for name in row.dtype.names: + data[params_index][name] = row[name] + + data[params_index]['index'] = cf_index + data[params_index]['GSA name'] = "CF: " + bio_act['name'] + str(bio_act['categories']) + + print('CF filtering resulted in including {} of {} characteriation factors.'.format( + len(data), + len(lca.cf_params), + )) + df = pd.DataFrame(data).T + df.rename(columns={'uncertainty_type': 'uncertainty type'}, inplace=True) + return df + + +def get_parameters_DF(mc): + """Function to make parameters dataframe""" + if bool(mc.parameter_data): # returns False if dict is empty + dfp = pd.DataFrame(mc.parameter_data).T + dfp['GSA name'] = "P: " + dfp['name'] + print('PARAMETERS:', len(dfp)) + return dfp + else: + print('PARAMETERS: None included.') + return pd.DataFrame() # return emtpy df + + +def get_exchange_values(matrix, indices): + """Get technosphere exchanges values from a list of exchanges + (row and column information)""" + return [matrix[i] for i in indices] + + +def get_X(matrix_list, indices): + """Get the input data to the GSA, i.e. A and B matrix values for each + model run.""" + X = np.zeros((len(matrix_list), len(indices))) + for row, M in enumerate(matrix_list): + X[row, :] = get_exchange_values(M, indices) + return X + + +def get_X_CF(mc, dfcf, method): + """Get the characterization factors used for each model run. Only those CFs + that are in the dfcf dataframe will be returned (i.e. by default only the + CFs that have uncertainties.""" + # get all CF inputs + CF_data = np.array(mc.CF_dict[method]) # has the same shape as the Xa and Xb below + + # reduce this to uncertain CFs only (if this was done for the dfcf) + params_indices = dfcf.index.values + + # if params_indices: + return CF_data[:, params_indices] + + +def get_X_P(dfp): + """Get the parameter values for each model run""" + lists = [d for d in dfp['values']] + return list(zip(*lists)) + + +def get_problem(X, names): + return { + 'num_vars': X.shape[1], + 'names': names, + 'bounds': list(zip(*(np.amin(X, axis=0), np.amax(X, axis=0)))), + } + + +class GlobalSensitivityAnalysis(object): + """Class for Global Sensitivity Analysis. + For now Delta Moment Independent Measure based on: + https://salib.readthedocs.io/en/latest/api.html#delta-moment-independent-measure + Builds on top of Monte Carlo Simulation results. + """ + + def __init__(self, mc): + self.update_mc(mc) + self.act_number = int() + self.method_number = int() + self.cutoff_technosphere = float() + self.cutoff_biosphere = float() + + def update_mc(self, mc): + "Update the Monte Carlo Simulation object (and results)." + try: + assert (isinstance(mc, MonteCarloLCA)) + self.mc = mc + except AssertionError: + raise AssertionError( + "mc should be an instance of MonteCarloLCA, but instead it is a {}.".format(type(mc)) + ) + + def perform_GSA(self, act_number=0, method_number=0, + cutoff_technosphere=0.01, cutoff_biosphere=0.01): + """Perform GSA for specific functional unit and LCIA method.""" + start = time() + + # set FU and method + try: + self.act_number = act_number + self.method_number = method_number + self.cutoff_technosphere = cutoff_technosphere + self.cutoff_biosphere = cutoff_biosphere + + self.fu = self.mc.cs['inv'][act_number] + self.activity = bw.get_activity(self.mc.rev_activity_index[act_number]) + self.method = self.mc.cs['ia'][method_number] + + except Exception as e: + traceback.print_exc() + # todo: QMessageBox.warning(self, 'Could not perform Delta analysis', str(e)) + print('Initializing the GSA failed.') + return None + + print('-- GSA --\n Project:', bw.projects.current, 'CS:', self.mc.cs_name, + 'Activity:', self.activity, 'Method:', self.method) + + # get non-stochastic LCA object with reverse dictionaries + self.lca = get_lca(self.fu, self.method) + + # ============================================================================= + # Filter exchanges and get metadata DataFrames + # ============================================================================= + dfs = [] + # technosphere + if self.mc.include_technosphere: + self.t_indices = filter_technosphere_exchanges(self.fu, self.method, + cutoff=cutoff_technosphere, + max_calc=1e4) + self.t_exchanges, self.t_indices = get_exchanges(self.lca, self.t_indices) + self.dft = get_exchanges_dataframe(self.t_exchanges, self.t_indices) + if not self.dft.empty: + dfs.append(self.dft) + + # biosphere + if self.mc.include_biosphere: + self.b_indices = filter_biosphere_exchanges(self.lca, cutoff=cutoff_biosphere) + self.b_exchanges, self.b_indices = get_exchanges(self.lca, self.b_indices, biosphere=True) + self.dfb = get_exchanges_dataframe(self.b_exchanges, self.b_indices, biosphere=True) + if not self.dfb.empty: + dfs.append(self.dfb) + + # characterization factors + if self.mc.include_cfs: + self.dfcf = get_CF_dataframe(self.lca, only_uncertain_CFs=True) # None if no stochastic CFs + if not self.dfcf.empty: + dfs.append(self.dfcf) + + # parameters + # todo: if parameters, include df, but remove exchanges from T and B (skipped for now) + self.dfp = get_parameters_DF(self.mc) # Empty df if no parameters + if not self.dfp.empty: + dfs.append(self.dfp) + + # Join dataframes to get metadata + self.metadata = pd.concat(dfs, axis=0, ignore_index=True, sort=False) + self.metadata.set_index('GSA name', inplace=True) + + # ============================================================================= + # GSA + # ============================================================================= + + # Get X (Technosphere, Biosphere and CF values) + X_list = list() + if self.mc.include_technosphere and self.t_indices: + self.Xa = get_X(self.mc.A_matrices, self.t_indices) + X_list.append(self.Xa) + if self.mc.include_biosphere and self.b_indices: + self.Xb = get_X(self.mc.B_matrices, self.b_indices) + X_list.append(self.Xb) + if self.mc.include_cfs and not self.dfcf.empty: + self.Xc = get_X_CF(self.mc, self.dfcf, self.method) + X_list.append(self.Xc) + if self.mc.include_parameters and not self.dfp.empty: + self.Xp = get_X_P(self.dfp) + X_list.append(self.Xp) + + self.X = np.concatenate(X_list, axis=1) + # print('X', self.X.shape) + + # Get Y (LCA scores) + self.Y = self.mc.get_results_dataframe(act_key=self.activity.key)[self.method].to_numpy() + self.Y = np.log(self.Y) # this makes it more robust for very uneven distributions of LCA results + # (e.g. toxicity related impacts); for not so large differences in LCIA results it should not matter + + # define problem + self.names = self.metadata.index # ['GSA name'] + # print('Names:', len(self.names)) + self.problem = get_problem(self.X, self.names) + + # perform delta analysis + time_delta = time() + self.Si = delta.analyze(self.problem, self.X, self.Y, print_to_console=False) + print('Delta analysis took {} seconds'.format(np.round(time() - time_delta, 2), )) + + # put GSA results in to dataframe + self.dfgsa = pd.DataFrame(self.Si, index=self.names).sort_values(by='delta', ascending=False) + self.dfgsa.index.names = ['GSA name'] + + # join with metadata + self.df_final = self.dfgsa.join(self.metadata, on='GSA name') + self.df_final.reset_index(inplace=True) + self.df_final['pedigree'] = [str(x) for x in self.df_final['pedigree']] + + print('GSA took {} seconds'.format(np.round(time() - start, 2))) + + def get_save_name(self): + save_name = self.mc.cs_name + '_' + str(self.mc.iterations) + '_' + self.activity['name'] + \ + '_' + str(self.method) + '.xlsx' + save_name = save_name.replace(',', '').replace("'", '').replace("/", '') + return save_name + + def export_GSA_output(self): + save_name = 'gsa_output_' + self.get_save_name() + self.df_final.to_excel(os.path.join(ab_settings.data_dir, save_name)) + + def export_GSA_input(self): + """Export the input data to the GSA with a human readible index""" + X_with_index = pd.DataFrame(self.X.T, index=self.metadata.index) + save_name = 'gsa_input_' + self.get_save_name() + X_with_index.to_excel(os.path.join(ab_settings.data_dir, save_name)) + +if __name__ == "__main__": + mc = perform_MonteCarlo_LCA(project='ei34', cs_name='kraft paper', iterations=20) + g = GlobalSensitivityAnalysis(mc) + g.perform_GSA(act_number=0, method_number=1, cutoff_technosphere=0.01, cutoff_biosphere=0.01) + g.export() \ No newline at end of file diff --git a/activity_browser/app/bwutils/utils.py b/activity_browser/app/bwutils/utils.py index 51f0d3d48..450fa74f1 100644 --- a/activity_browser/app/bwutils/utils.py +++ b/activity_browser/app/bwutils/utils.py @@ -3,6 +3,7 @@ from itertools import chain from typing import Iterable, List, NamedTuple, Optional +import brightway2 as bw from bw2data import config from bw2data.backends.peewee import ActivityDataset, ExchangeDataset from bw2data.parameters import ( @@ -29,6 +30,22 @@ class Parameter(NamedTuple): amount: float = 1.0 param_type: Optional[str] = None + def as_gsa_tuple(self) -> tuple: + """Return the parameter data formatted as follows: + - Parameter name + - Scope [global/activity] + - Associated activity [or None] + - Value + """ + if self.group == "project" or self.group in bw.databases: + scope = "global" + associated = None + else: + scope = "activity" + p = ActivityParameter.get(name=self.name, group=self.group) + associated = (p.database, p.code) + return self.name, scope, associated, self.amount + class Key(NamedTuple): database: str @@ -143,12 +160,16 @@ def update(self, values: Iterable[float]) -> None: if not np.isnan(v): self.data[i] = p._replace(amount=v) + def to_gsa(self) -> List[tuple]: + """Formats all of the parameters in the list for handling in a GSA.""" + return [p.as_gsa_tuple() for p in self.data] + class Indices(UserList): data: List[Index] array_dtype = [ - ('input', ' np.ndarray: @@ -159,7 +180,7 @@ def mock_params(self, values) -> np.ndarray: assert len(self.data) == len(values) data = np.zeros(len(self.data), dtype=self.array_dtype) for i, d in enumerate(self.data): - data[i] = (*d.ids_exc_type, values[i]) + data[i] = (d.input, d.output, d.exchange_type, values[i]) return data diff --git a/activity_browser/app/controller.py b/activity_browser/app/controller.py index 57ca92dcc..cf1a55540 100644 --- a/activity_browser/app/controller.py +++ b/activity_browser/app/controller.py @@ -117,27 +117,10 @@ def import_database_wizard(self): self.db_wizard = DatabaseImportWizard() def switch_brightway2_dir_path(self, dirpath): - if dirpath == bw.projects._base_data_dir: - return # dirpath is already loaded - try: - assert os.path.isdir(dirpath) - bw.projects._base_data_dir = dirpath - bw.projects._base_logs_dir = os.path.join(dirpath, "logs") - # create folder if it does not yet exist - if not os.path.isdir(bw.projects._base_logs_dir): - os.mkdir(bw.projects._base_logs_dir) - # load new brightway directory - bw.projects.db = SubstitutableDatabase( - os.path.join(bw.projects._base_data_dir, "projects.db"), - [ProjectDataset] - ) - print('Loaded brightway2 data directory: {}'.format(bw.projects._base_data_dir)) + if bc.switch_brightway2_dir(dirpath): self.change_project(ab_settings.startup_project, reload=True) signals.databases_changed.emit() - except AssertionError: - print('Could not access BW_DIR as specified in settings.py') - # PROJECT def change_project_dialog(self): project_names = sorted([x.name for x in bw.projects]) diff --git a/activity_browser/app/signals.py b/activity_browser/app/signals.py index 375d78a08..7dbb8e80e 100644 --- a/activity_browser/app/signals.py +++ b/activity_browser/app/signals.py @@ -94,6 +94,9 @@ class Signals(QObject): method_selected = Signal(tuple) method_tabs_changed = Signal() + # Monte Carlo LCA + monte_carlo_finished = Signal() + # Qt Windows update_windows = Signal() new_statusbar_message = Signal(str) diff --git a/activity_browser/app/ui/tables/lca_results.py b/activity_browser/app/ui/tables/lca_results.py index 8d50e6fb8..8bf700be7 100644 --- a/activity_browser/app/ui/tables/lca_results.py +++ b/activity_browser/app/ui/tables/lca_results.py @@ -7,7 +7,8 @@ class LCAResultsTable(ABDataFrameView): @dataframe_sync def sync(self, df): - self.dataframe = df + # self.dataframe = df + self.dataframe = df.replace(np.nan, '', regex=True) class InventoryTable(ABDataFrameView): diff --git a/activity_browser/app/ui/tabs/LCA_results_tabs.py b/activity_browser/app/ui/tabs/LCA_results_tabs.py index 061cebbc8..291837865 100644 --- a/activity_browser/app/ui/tabs/LCA_results_tabs.py +++ b/activity_browser/app/ui/tabs/LCA_results_tabs.py @@ -5,6 +5,7 @@ """ from collections import namedtuple +import traceback from typing import List, Optional, Union from bw2calc.errors import BW2CalcError @@ -15,11 +16,11 @@ ) from PySide2 import QtGui, QtCore from stats_arrays.errors import InvalidParamsError -import traceback from ...bwutils import ( - Contributions, CSMonteCarloLCA, MLCA, PresamplesContributions, - PresamplesMLCA, SuperstructureContributions, SuperstructureMLCA, + Contributions, MonteCarloLCA, MLCA, PresamplesMLCA, + PresamplesContributions, SuperstructureContributions, + SuperstructureMLCA, GlobalSensitivityAnalysis, commontasks as bc ) from ...signals import signals @@ -58,7 +59,7 @@ def get_unit(method: tuple, relative: bool = False) -> str: # Special namedtuple for the LCAResults TabWidget. Tabs = namedtuple( - "tabs", ("inventory", "results", "ef", "process", "mc", "sankey") + "tabs", ("inventory", "results", "ef", "process", "sankey", "mc", "gsa") ) Relativity = namedtuple("relativity", ("relative", "absolute")) ExportTable = namedtuple("export_table", ("label", "copy", "csv", "excel")) @@ -88,7 +89,7 @@ def __init__(self, name: str, presamples=None, parent=None): self.presamples = presamples self.mlca: Optional[Union[MLCA, PresamplesMLCA, SuperstructureMLCA]] = None self.contributions: Optional[Contributions] = None - self.mc: Optional[CSMonteCarloLCA] = None + self.mc: Optional[MonteCarloLCA] = None self.method_dict = dict() self.single_func_unit = False self.single_method = False @@ -103,17 +104,18 @@ def __init__(self, name: str, presamples=None, parent=None): results=LCAResultsTab(self), ef=ElementaryFlowContributionTab(self), process=ProcessContributionsTab(self), - # mc=None if self.mc is None else MonteCarloTab(self), - mc=MonteCarloTab(self), sankey=SankeyNavigatorWidget(self.cs_name, parent=self), + mc=MonteCarloTab(self), # mc=None if self.mc is None else MonteCarloTab(self), + gsa=GSATab(self), ) self.tab_names = Tabs( inventory="Inventory", results="LCA Results", ef="EF Contributions", process="Process Contributions", - mc="Monte Carlo", sankey="Sankey", + mc="Monte Carlo", + gsa="Sensitivity Analysis", ) self.setup_tabs() self.setCurrentWidget(self.tabs.results) @@ -141,7 +143,7 @@ def do_calculations(self): except AssertionError as e: raise BW2CalcError("Scenario LCA failed.", str(e)).with_traceback(e.__traceback__) self.mlca.calculate() - self.mc = CSMonteCarloLCA(self.cs_name) + self.mc = MonteCarloLCA(self.cs_name) # self.mct = CSMonteCarloLCAThread() # self.mct.start() @@ -986,9 +988,9 @@ def add_MC_ui_elements(self): layout_mc = QVBoxLayout() # H-LAYOUT start simulation - self.button_run = QPushButton('Run Simulation') + self.button_run = QPushButton('Run') self.label_iterations = QLabel('Iterations:') - self.iterations = QLineEdit('10') + self.iterations = QLineEdit('20') self.iterations.setFixedWidth(40) self.iterations.setValidator(QtGui.QIntValidator(1, 1000)) self.label_seed = QLabel('Random seed:') @@ -1089,6 +1091,7 @@ def calculate_mc_lca(self): try: self.parent.mc.calculate(iterations=iterations, seed=seed, **includes) + signals.monte_carlo_finished.emit() self.update_mc() except InvalidParamsError as e: # This can occur if uncertainty data is missing or otherwise broken # print(e) @@ -1194,12 +1197,198 @@ def update_table(self): self.table.sync(self.df) +class GSATab(NewAnalysisTab): + def __init__(self, parent=None): + super(GSATab, self).__init__(parent) + self.parent = parent + + self.GSA = GlobalSensitivityAnalysis(self.parent.mc) + + self.layout.addLayout(get_header_layout('Global Sensitivity Analysis')) + self.scenario_box = None + + self.add_GSA_ui_elements() + + self.table = LCAResultsTable() + self.table.table_name = 'GSA_' + self.parent.cs_name + self.layout.addWidget(self.table) + self.table.hide() + # self.plot = MonteCarloPlot(self.parent) + # self.plot.hide() + # self.plot.plot_name = 'GSA_' + self.parent.cs_name + # self.layout.addWidget(self.plot) + + self.export_widget = self.build_export(has_plot=False, has_table=True) + self.layout.addWidget(self.export_widget) + self.layout.setAlignment(QtCore.Qt.AlignTop) + self.connect_signals() + + def connect_signals(self): + self.button_run.clicked.connect(self.calculate_gsa) + signals.monte_carlo_finished.connect(self.monte_carlo_finished) + + def add_GSA_ui_elements(self): + # H-LAYOUT SETTINGS ROW 1 + + # run button + self.button_run = QPushButton('Run') + self.button_run.setEnabled(False) + + # functional unit selection + self.label_fu = QLabel('Functional unit:') + self.combobox_fu = QComboBox() + + # method selection + self.label_methods = QLabel('LCIA method:') + self.combobox_methods = QComboBox() + + # arrange layout + self.hlayout_row1 = QHBoxLayout() + self.hlayout_row1.addWidget(self.button_run) + self.hlayout_row1.addWidget(self.label_fu) + self.hlayout_row1.addWidget(self.combobox_fu) + self.hlayout_row1.addWidget(self.label_methods) + self.hlayout_row1.addWidget(self.combobox_methods) + + # self.hlayout_row1.addWidget(self.fu_selection_widget) + # self.hlayout_row1.addWidget(self.method_selection_widget) + self.hlayout_row1.addStretch(1) + + # H-LAYOUT SETTINGS ROW 2 + self.hlayout_row2 = QHBoxLayout() + + # cutoff technosphere + self.label_cutoff_technosphere = QLabel('Cut-off technosphere:') + self.cutoff_technosphere = QLineEdit('0.01') + self.cutoff_technosphere.setFixedWidth(40) + self.cutoff_technosphere.setValidator(QtGui.QDoubleValidator(0.0, 1.0, 5)) + + # cutoff biosphere + self.label_cutoff_biosphere = QLabel('Cut-off biosphere:') + self.cutoff_biosphere = QLineEdit('0.01') + self.cutoff_biosphere.setFixedWidth(40) + self.cutoff_biosphere.setValidator(QtGui.QDoubleValidator(0.0, 1.0, 5)) + + # export GSA input/output data automatically with run + self.checkbox_export_data_automatically = QCheckBox('Save input/output data to Excel after run') + self.checkbox_export_data_automatically.setChecked(False) + + # # exclude Pedigree + # self.checkbox_pedigree = QCheckBox('Include Pedigree uncertainties') + # self.checkbox_pedigree.setChecked(True) + + # arrange layout + self.hlayout_row2.addWidget(self.label_cutoff_technosphere) + self.hlayout_row2.addWidget(self.cutoff_technosphere) + self.hlayout_row2.addWidget(self.label_cutoff_biosphere) + self.hlayout_row2.addWidget(self.cutoff_biosphere) + self.hlayout_row2.addWidget(self.checkbox_export_data_automatically) + # self.hlayout_row2.addWidget(self.checkbox_pedigree) + self.hlayout_row2.addStretch(1) + + # OVERALL LAYOUT OF SETTINGS + self.layout_settings = QVBoxLayout() + self.layout_settings.addLayout(self.hlayout_row1) + self.layout_settings.addLayout(self.hlayout_row2) + self.widget_settings = QWidget() + self.widget_settings.setLayout(self.layout_settings) + + # add to GSA layout + self.label_monte_carlo_first = QLabel('You need to run a Monte Carlo Simulation first.') + self.layout.addWidget(self.label_monte_carlo_first) + self.layout.addWidget(self.widget_settings) + + # at start + # todo: this is just for development, should be reversed later: + self.widget_settings.hide() + # self.label_monte_carlo_first.hide() + + def update_tab(self): + self.update_combobox(self.combobox_methods, [str(m) for m in self.parent.mc.methods]) + self.update_combobox(self.combobox_fu, list(self.parent.mlca.func_unit_translation_dict.keys())) + + def monte_carlo_finished(self): + self.button_run.setEnabled(True) + self.widget_settings.show() + self.label_monte_carlo_first.hide() + + def calculate_gsa(self): + act_number = self.combobox_fu.currentIndex() + method_number = self.combobox_methods.currentIndex() + cutoff_technosphere = float(self.cutoff_technosphere.text()) + cutoff_biosphere = float(self.cutoff_biosphere.text()) + # print('Calculating GSA for: ', act_number, method_number, cutoff_technosphere, cutoff_biosphere) + + try: + self.GSA.perform_GSA(act_number=act_number, method_number=method_number, + cutoff_technosphere=cutoff_technosphere, cutoff_biosphere=cutoff_biosphere) + # self.update_mc() + except Exception as e: # Catch any error... + traceback.print_exc() + message = str(e) + message_addition = '' + if message == 'singular matrix': + message_addition = "\nIn order to avoid this happening, please increase the Monte Carlo iterations (e.g. to above 50)." + elif message == "`dataset` input should have multiple elements.": + message_addition = "\nIn order to avoid this happening, please increase the Monte Carlo iterations (e.g. to above 50)." + elif message == "No objects to concatenate": + message_addition = "\nThe reason for this is likely that there are no uncertain exchanges. Please check " \ + "the checkboxes in the Monte Carlo tab." + QMessageBox.warning(self, 'Could not perform GSA', str(message) + message_addition) + + self.update_gsa() + + def update_gsa(self, cs_name=None): + self.df = getattr(self.GSA, "df_final", None) + if self.df is None: + return + self.update_table() + self.table.show() + self.export_widget.show() + + self.table.table_name = 'gsa_output_' + self.GSA.get_save_name() + + if self.checkbox_export_data_automatically.isChecked(): + print("EXPORTING DATA") + self.GSA.export_GSA_input() + self.GSA.export_GSA_output() + + def update_plot(self, method): + pass + + def update_table(self): + self.table.sync(self.df) + + def build_export(self, has_table: bool = True, has_plot: bool = True) -> QWidget: + """Construct the export layout but set it into a widget because we + want to hide it.""" + export_layout = super().build_export(has_table, has_plot) + export_widget = QWidget() + export_widget.setLayout(export_layout) + # Hide widget until MC is calculated + export_widget.hide() + return export_widget + + # def set_filename(self, optional_fields: dict = None): + # """Given a dictionary of fields, put together a usable filename for the plot and table.""" + # save_name = 'gsa_output_' + self.mc.cs_name + '_' + str(self.mc.iterations) + '_' + self.activity['name'] + \ + # '_' + str(self.method) + '.xlsx' + # save_name = save_name.replace(',', '').replace("'", '').replace("/", '') + # self.table.table_name = save_name + # optional = optional_fields or {} + # fields = ( + # self.parent.cs_name, self.contribution_fn, optional.get("method"), + # optional.get("functional_unit"), self.unit + # ) + # filename = '_'.join((str(x) for x in fields if x is not None)) + + class MonteCarloWorkerThread(QtCore.QThread): """A worker for Monte Carlo simulations. Unfortunately, pyparadiso does not allow parallel calculations on Windows (crashes). So this is for future reference in case this issue is solved... """ - def set_mc(self, mc, iterations=10): + def set_mc(self, mc, iterations=20): self.mc = mc self.iterations = iterations diff --git a/appveyor.yml b/appveyor.yml index cc663cbc3..4366cb25a 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -19,7 +19,7 @@ install: # - conda update -q conda # Yeet. - conda info -a # Install package requirements & test suite - - conda install -q -c conda-forge -c cmutel -c haasad -c pascallesage arrow brightway2 bw2io bw2data eidl fuzzywuzzy matplotlib-base networkx pandas pyside2=5.13 seaborn presamples openpyxl "pytest>=5.2" pytest-qt pytest-mock + - conda install -q -c conda-forge -c cmutel -c haasad -c pascallesage arrow brightway2 bw2io bw2data eidl fuzzywuzzy matplotlib-base networkx pandas pyside2=5.13 salib seaborn presamples openpyxl "pytest>=5.2" pytest-qt pytest-mock test_script: - py.test diff --git a/ci/travis/recipe/meta.yaml b/ci/travis/recipe/meta.yaml index 1f5e506e4..496841ceb 100644 --- a/ci/travis/recipe/meta.yaml +++ b/ci/travis/recipe/meta.yaml @@ -33,6 +33,7 @@ requirements: - networkx - pandas >=0.24.1 - pyside2 >=5.13.1 + - salib - seaborn - presamples - openpyxl diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 1f8f1b60d..5faafc883 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -34,6 +34,7 @@ requirements: - networkx - pandas >=0.24.1 - pyside2 >=5.13.1 + - salib - seaborn - presamples - openpyxl