From 6359dd76a6128aeb31698628adf3622ce6f4e371 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Fri, 22 Sep 2023 10:48:39 +0200 Subject: [PATCH 1/8] Add first iteration of product contributions --- .../layouts/tabs/LCA_results_tabs.py | 264 +++++++++++++++++- 1 file changed, 260 insertions(+), 4 deletions(-) diff --git a/activity_browser/layouts/tabs/LCA_results_tabs.py b/activity_browser/layouts/tabs/LCA_results_tabs.py index 63cc3f214..e09f71ff4 100644 --- a/activity_browser/layouts/tabs/LCA_results_tabs.py +++ b/activity_browser/layouts/tabs/LCA_results_tabs.py @@ -6,8 +6,11 @@ from collections import namedtuple import traceback -from typing import List, Optional, Union +from typing import List, Tuple, Optional, Union + +import numpy as np import pandas as pd +import warnings from PySide2.QtWidgets import ( QWidget, QTabWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QRadioButton, @@ -18,6 +21,7 @@ ) from PySide2 import QtGui, QtCore from stats_arrays.errors import InvalidParamsError +import brightway2 as bw from ...bwutils import ( Contributions, MonteCarloLCA, MLCA, @@ -34,6 +38,7 @@ from ...ui.tables import ContributionTable, InventoryTable, LCAResultsTable from ...ui.widgets import CutoffMenu, SwitchComboBox from ...ui.web import SankeyNavigatorWidget +from ...bwutils.superstructure.graph_traversal_with_scenario import GraphTraversalWithScenario from .base import BaseRightTab import logging @@ -69,7 +74,7 @@ def get_unit(method: tuple, relative: bool = False) -> str: # Special namedtuple for the LCAResults TabWidget. Tabs = namedtuple( - "tabs", ("inventory", "results", "ef", "process", "sankey", "mc", "gsa") + "tabs", ("inventory", "results", "ef", "process", "product", "sankey", "mc", "gsa") ) Relativity = namedtuple("relativity", ("relative", "absolute")) ExportTable = namedtuple("export_table", ("label", "copy", "csv", "excel")) @@ -107,7 +112,6 @@ def __init__(self, data: dict, parent=None): self.setMovable(True) self.setVisible(False) - self.visible = False QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) self.mlca, self.contributions, self.mc = calculations.do_LCA_calculations(data) @@ -120,6 +124,7 @@ def __init__(self, data: dict, parent=None): results=LCAResultsTab(self), ef=ElementaryFlowContributionTab(self), process=ProcessContributionsTab(self), + product=ProductContributionsTab(self.cs_name, self), sankey=SankeyNavigatorWidget(self.cs_name, parent=self), mc=MonteCarloTab(self), # mc=None if self.mc is None else MonteCarloTab(self), gsa=GSATab(self), @@ -129,6 +134,7 @@ def __init__(self, data: dict, parent=None): results="LCA Results", ef="EF Contributions", process="Process Contributions", + product="Product Contributions", sankey="Sankey", mc="Monte Carlo", gsa="Sensitivity Analysis", @@ -521,7 +527,6 @@ def elementary_flows_contributing_to_IA_methods(self, contributary: bool = True, return data.loc[data['code'].isin(new_flows)] - def update_table(self): """Update the table.""" inventory = "biosphere" if self.radio_button_biosphere.isChecked() else "technosphere" @@ -1034,6 +1039,257 @@ def update_dataframe(self, *args, **kwargs): ) +class ProductContributionsTab(ContributionTab): + """Class for the 'Product Contributions' sub-tab. + + This tab allows for analysis of first-level product contributions. + The direct impact (from biosphere exchanges from the FU) + and cumulative impacts from all exchange inputs to the FU (first level) are calculated. + + e.g. the direct emissions from steel production and the cumulative impact for all electricity input + into that activity. This works on the basis of input products and their total (cumulative) impact, scaled to + how much of that product is needed in the FU. + + Example questions that can be answered by this tab: + What is the contribution of electricity (product) to reference flow XXX? + Which input product contributes the most to impact category YYY? + What products contribute most to reference flow ZZZ? + + Shows: + Compare options button to change between 'Reference Flows' and 'Impact Categories' + 'Impact Category'/'Reference Flow' chooser with aggregation method + Plot/Table on/off and Relative/Absolute options for data + Plot/Table + Export options + """ + + def __init__(self, cs_name, parent=None): + super().__init__(parent) + + self.cache = {'totals': {}} # We cache the calculated data, as it can take some time to generate. + # We cache the individual calculation results, as they are re-used in multiple views + # e.g. FU1 x method1 x scenario1 + # may be seen in both 'Reference Flows' and 'Impact Categories', just with different axes. + # we also cache totals, not for calculation speed, but to be able to easily convert for relative results + self.caching = True # set to False to disable caching for debug + + self.layout.addLayout(get_header_layout('Product Contributions')) + combobox = self.build_combobox(has_method=True, has_func=True) + self.layout.addLayout(combobox) + self.layout.addWidget(horizontal_line()) + self.layout.addWidget(self.build_main_space()) + self.layout.addLayout(self.build_export(True, True)) + + # get relevant data from calculation setup + self.cs = cs_name + func_units = bw.calculation_setups[self.cs]['inv'] + self.func_keys = [list(fu.keys())[0] for fu in func_units] # extract a list of keys from the functional units + self.func_units = [ + {bw.get_activity(k): v for k, v in fu.items()} + for fu in func_units + ] + self.methods = bw.calculation_setups[self.cs]['ia'] + + self.contribution_fn = 'Product contributions' + self.switches.configure(self.has_func, self.has_method) + self.connect_signals() + self.toggle_comparisons(self.switches.indexes.func) + + self.combobox_menu.agg_label.setVisible(False) + self.combobox_menu.agg.setVisible(False) + + def update_dataframe(self, *args, **kwargs): + """Retrieve the product contributions.""" + # get the right data + if self.has_scenarios: + scenario_index = self.combobox_menu.scenario.currentIndex() + else: + scenario_index = None + method_index = self.combobox_menu.method.currentIndex() + method = self.methods[method_index] + demand_index = self.combobox_menu.func.currentIndex() + demand = self.func_units[demand_index] + demand_key = self.func_keys[demand_index] + + all_data = [] + compare = self.switches.currentText() + if compare == 'Reference Flows': + # run the analysis for every reference flow + for demand_index, demand in enumerate(self.func_units): + demand_key = self.func_keys[demand_index] + cache_key = (demand_key, method_index, scenario_index) + if self.caching and self.cache.get(cache_key, False): + all_data.append([demand_key, self.cache[cache_key]]) + continue + data = self.calculate_contributions(demand, demand_key, + method=method, method_index=method_index, + scenario_lca=self.has_scenarios, scenario_index=scenario_index, + ) + if data: + data = self.reformat_data(data) + else: + data = {'Total': 0} + all_data.append([demand_key, data]) + self.cache[cache_key] = data + elif compare == 'Impact Categories': + # run the analysis for every method + for method_index, method in enumerate(self.methods): + cache_key = (demand_key, method_index, scenario_index) + if self.caching and self.cache.get(cache_key, False): + all_data.append([method, self.cache[cache_key]]) + continue + data = self.calculate_contributions(demand, demand_key, + method=method, method_index=method_index, + scenario_lca=self.has_scenarios, scenario_index=scenario_index, + ) + if data: + data = self.reformat_data(data) + else: + data = {'Total': 0} + all_data.append([method, data]) + self.cache[cache_key] = data + elif compare == 'Scenarios': + # run the analysis for every scenario + orig_idx = self.combobox_menu.scenario.currentIndex() + for scenario_index in range(self.combobox_menu.scenario.count()): + self.combobox_menu.scenario.setCurrentIndex(scenario_index) + scenario = self.combobox_menu.scenario.currentText() + + cache_key = (demand_key, method_index, scenario_index) + if self.caching and self.cache.get(cache_key, False): + all_data.append([scenario, self.cache[cache_key]]) + continue + data = self.calculate_contributions(demand, demand_key, + method=method, method_index=method_index, + scenario_lca=self.has_scenarios, scenario_index=scenario_index, + ) + if data: + data = self.reformat_data(data) + else: + data = {'Total': 0} + all_data.append([scenario, data]) + self.cache[cache_key] = data + self.combobox_menu.scenario.setCurrentIndex(orig_idx) + df = self.data_to_df(all_data, compare) + # TODO manage empty dataframe so overall calculation doesn't fail with plot generation + return df + + def calculate_contributions(self, demand, demand_key, + method, method_index: int = None, + scenario_lca: bool = False, scenario_index: int = None) -> Optional[dict]: + """Calculate LCA, do graph traversal on the first level of activities.""" + technosphere = bw.get_activity(demand_key).technosphere() + + max_calc = len([exch for exch in technosphere if + exch.input.key != exch.output.key]) # get the amount of exchanges that are not to self + + with warnings.catch_warnings(): + # Ignore the calculation count warning, as we don't care about it in this situation + warnings.filterwarnings("ignore", message="Stopping traversal due to calculation count.") + try: + if scenario_lca: + self.parent.mlca.update_lca_calculation_for_sankey(scenario_index, demand, method_index) + data = GraphTraversalWithScenario(self.parent.mlca).calculate(demand, method, cutoff=0, + max_calc=max_calc) + else: + data = bw.GraphTraversal().calculate(demand, method, cutoff=0, max_calc=max_calc) + except (ValueError, ZeroDivisionError) as e: + log.info('{}, no results calculated for {}'.format(e, method)) + return + return data + + def reformat_data(self, data: dict) -> dict: + """Reformat the data into a useable format.""" + lca = data["lca"] + demand = list(lca.demand.items())[0] + demand_key = list(lca.demand.keys())[0].key + total = lca.score + reverse_activity_dict = {v: k for k, v in lca.activity_dict.items()} + + # get the impact data per key + contributions = { + bw.get_activity(reverse_activity_dict[edge['from']]).key: edge['impact'] for edge in data["edges"] + if all(i != -1 for i in (edge["from"], edge["to"])) + } + # get impact for the total and demand + diff = total - sum([v for v in contributions.values()]) # for the demand process, calculate by the difference + contributions[demand_key] = diff + contributions['Total'] = total + return contributions + + def data_to_df(self, all_data: List[Tuple[object, dict]], compare: str) -> pd.DataFrame: + """Convert the provided data into a dataframe.""" + unique_keys = [] + # get all the unique keys: + d = {'index': ['Total'], 'reference product': [''], 'name': [''], + 'location': [''], 'unit': [''], 'database': ['']} + meta_cols = set(d.keys()) + for i, (item, data) in enumerate(all_data): + print('++ COL:', item, data) + unique_keys += list(data.keys()) + # already add the total with right column formatting depending on compares + if compare == 'Reference Flows': + col_name = self.metadata_to_index(self.key_to_metadata(item)) + elif compare == 'Impact Categories': + col_name = self.metadata_to_index(list(item)) + elif compare == 'Scenarios': + col_name = item + + self.cache['totals'][col_name] = data['Total'] + if self.relative: + d[col_name] = [1] + else: + d[col_name] = [data['Total']] + + all_data[i] = item, data, col_name + unique_keys = set(unique_keys) + + # convert to dict format to feed into dataframe + for key in unique_keys: + if key == 'Total': + continue + # get metadata + metadata = self.key_to_metadata(key) + d['index'].append(self.metadata_to_index(metadata)) + d['reference product'].append(metadata[0]) + d['name'].append(metadata[1]) + d['location'].append(metadata[2]) + d['unit'].append(metadata[3]) + d['database'].append(metadata[4]) + # check for each dataset if we have values, otherwise add np.nan + for item, data, col_name in all_data: + if val := data.get(key, False): + if self.relative: + value = val / self.cache['totals'][col_name] + else: + value = val + else: + value = np.nan + d[col_name].append(value) + df = pd.DataFrame(d) + check_cols = list(set(df.columns) - meta_cols) + df = df.dropna(subset=check_cols, how='all') + df.sort_values(by=col_name, ascending=False) # temporary sorting solution, just sort on the last column. + return df + + def key_to_metadata(self, key: tuple) -> list: + """Convert the key information to list with metadata. + + format: + [reference product, activity name, location, unit, database] + """ + act = bw.get_activity(key) + return [act.get('reference product'), act.get('name'), act.get('location'), act.get('unit'), key[0]] + + def metadata_to_index(self, data: list) -> str: + """Convert list to formatted index. + + format: + reference product | activity name | location | unit | database + """ + return ' | '.join(data) + + class CorrelationsTab(NewAnalysisTab): def __init__(self, parent): super().__init__(parent) From ecdfad26f756ee6a68b258a619a3c5e4dec24e17 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Fri, 22 Sep 2023 10:48:39 +0200 Subject: [PATCH 2/8] Add first iteration of product contributions --- .../layouts/tabs/LCA_results_tabs.py | 264 +++++++++++++++++- 1 file changed, 260 insertions(+), 4 deletions(-) diff --git a/activity_browser/layouts/tabs/LCA_results_tabs.py b/activity_browser/layouts/tabs/LCA_results_tabs.py index 63cc3f214..e09f71ff4 100644 --- a/activity_browser/layouts/tabs/LCA_results_tabs.py +++ b/activity_browser/layouts/tabs/LCA_results_tabs.py @@ -6,8 +6,11 @@ from collections import namedtuple import traceback -from typing import List, Optional, Union +from typing import List, Tuple, Optional, Union + +import numpy as np import pandas as pd +import warnings from PySide2.QtWidgets import ( QWidget, QTabWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QRadioButton, @@ -18,6 +21,7 @@ ) from PySide2 import QtGui, QtCore from stats_arrays.errors import InvalidParamsError +import brightway2 as bw from ...bwutils import ( Contributions, MonteCarloLCA, MLCA, @@ -34,6 +38,7 @@ from ...ui.tables import ContributionTable, InventoryTable, LCAResultsTable from ...ui.widgets import CutoffMenu, SwitchComboBox from ...ui.web import SankeyNavigatorWidget +from ...bwutils.superstructure.graph_traversal_with_scenario import GraphTraversalWithScenario from .base import BaseRightTab import logging @@ -69,7 +74,7 @@ def get_unit(method: tuple, relative: bool = False) -> str: # Special namedtuple for the LCAResults TabWidget. Tabs = namedtuple( - "tabs", ("inventory", "results", "ef", "process", "sankey", "mc", "gsa") + "tabs", ("inventory", "results", "ef", "process", "product", "sankey", "mc", "gsa") ) Relativity = namedtuple("relativity", ("relative", "absolute")) ExportTable = namedtuple("export_table", ("label", "copy", "csv", "excel")) @@ -107,7 +112,6 @@ def __init__(self, data: dict, parent=None): self.setMovable(True) self.setVisible(False) - self.visible = False QApplication.setOverrideCursor(QtCore.Qt.WaitCursor) self.mlca, self.contributions, self.mc = calculations.do_LCA_calculations(data) @@ -120,6 +124,7 @@ def __init__(self, data: dict, parent=None): results=LCAResultsTab(self), ef=ElementaryFlowContributionTab(self), process=ProcessContributionsTab(self), + product=ProductContributionsTab(self.cs_name, self), sankey=SankeyNavigatorWidget(self.cs_name, parent=self), mc=MonteCarloTab(self), # mc=None if self.mc is None else MonteCarloTab(self), gsa=GSATab(self), @@ -129,6 +134,7 @@ def __init__(self, data: dict, parent=None): results="LCA Results", ef="EF Contributions", process="Process Contributions", + product="Product Contributions", sankey="Sankey", mc="Monte Carlo", gsa="Sensitivity Analysis", @@ -521,7 +527,6 @@ def elementary_flows_contributing_to_IA_methods(self, contributary: bool = True, return data.loc[data['code'].isin(new_flows)] - def update_table(self): """Update the table.""" inventory = "biosphere" if self.radio_button_biosphere.isChecked() else "technosphere" @@ -1034,6 +1039,257 @@ def update_dataframe(self, *args, **kwargs): ) +class ProductContributionsTab(ContributionTab): + """Class for the 'Product Contributions' sub-tab. + + This tab allows for analysis of first-level product contributions. + The direct impact (from biosphere exchanges from the FU) + and cumulative impacts from all exchange inputs to the FU (first level) are calculated. + + e.g. the direct emissions from steel production and the cumulative impact for all electricity input + into that activity. This works on the basis of input products and their total (cumulative) impact, scaled to + how much of that product is needed in the FU. + + Example questions that can be answered by this tab: + What is the contribution of electricity (product) to reference flow XXX? + Which input product contributes the most to impact category YYY? + What products contribute most to reference flow ZZZ? + + Shows: + Compare options button to change between 'Reference Flows' and 'Impact Categories' + 'Impact Category'/'Reference Flow' chooser with aggregation method + Plot/Table on/off and Relative/Absolute options for data + Plot/Table + Export options + """ + + def __init__(self, cs_name, parent=None): + super().__init__(parent) + + self.cache = {'totals': {}} # We cache the calculated data, as it can take some time to generate. + # We cache the individual calculation results, as they are re-used in multiple views + # e.g. FU1 x method1 x scenario1 + # may be seen in both 'Reference Flows' and 'Impact Categories', just with different axes. + # we also cache totals, not for calculation speed, but to be able to easily convert for relative results + self.caching = True # set to False to disable caching for debug + + self.layout.addLayout(get_header_layout('Product Contributions')) + combobox = self.build_combobox(has_method=True, has_func=True) + self.layout.addLayout(combobox) + self.layout.addWidget(horizontal_line()) + self.layout.addWidget(self.build_main_space()) + self.layout.addLayout(self.build_export(True, True)) + + # get relevant data from calculation setup + self.cs = cs_name + func_units = bw.calculation_setups[self.cs]['inv'] + self.func_keys = [list(fu.keys())[0] for fu in func_units] # extract a list of keys from the functional units + self.func_units = [ + {bw.get_activity(k): v for k, v in fu.items()} + for fu in func_units + ] + self.methods = bw.calculation_setups[self.cs]['ia'] + + self.contribution_fn = 'Product contributions' + self.switches.configure(self.has_func, self.has_method) + self.connect_signals() + self.toggle_comparisons(self.switches.indexes.func) + + self.combobox_menu.agg_label.setVisible(False) + self.combobox_menu.agg.setVisible(False) + + def update_dataframe(self, *args, **kwargs): + """Retrieve the product contributions.""" + # get the right data + if self.has_scenarios: + scenario_index = self.combobox_menu.scenario.currentIndex() + else: + scenario_index = None + method_index = self.combobox_menu.method.currentIndex() + method = self.methods[method_index] + demand_index = self.combobox_menu.func.currentIndex() + demand = self.func_units[demand_index] + demand_key = self.func_keys[demand_index] + + all_data = [] + compare = self.switches.currentText() + if compare == 'Reference Flows': + # run the analysis for every reference flow + for demand_index, demand in enumerate(self.func_units): + demand_key = self.func_keys[demand_index] + cache_key = (demand_key, method_index, scenario_index) + if self.caching and self.cache.get(cache_key, False): + all_data.append([demand_key, self.cache[cache_key]]) + continue + data = self.calculate_contributions(demand, demand_key, + method=method, method_index=method_index, + scenario_lca=self.has_scenarios, scenario_index=scenario_index, + ) + if data: + data = self.reformat_data(data) + else: + data = {'Total': 0} + all_data.append([demand_key, data]) + self.cache[cache_key] = data + elif compare == 'Impact Categories': + # run the analysis for every method + for method_index, method in enumerate(self.methods): + cache_key = (demand_key, method_index, scenario_index) + if self.caching and self.cache.get(cache_key, False): + all_data.append([method, self.cache[cache_key]]) + continue + data = self.calculate_contributions(demand, demand_key, + method=method, method_index=method_index, + scenario_lca=self.has_scenarios, scenario_index=scenario_index, + ) + if data: + data = self.reformat_data(data) + else: + data = {'Total': 0} + all_data.append([method, data]) + self.cache[cache_key] = data + elif compare == 'Scenarios': + # run the analysis for every scenario + orig_idx = self.combobox_menu.scenario.currentIndex() + for scenario_index in range(self.combobox_menu.scenario.count()): + self.combobox_menu.scenario.setCurrentIndex(scenario_index) + scenario = self.combobox_menu.scenario.currentText() + + cache_key = (demand_key, method_index, scenario_index) + if self.caching and self.cache.get(cache_key, False): + all_data.append([scenario, self.cache[cache_key]]) + continue + data = self.calculate_contributions(demand, demand_key, + method=method, method_index=method_index, + scenario_lca=self.has_scenarios, scenario_index=scenario_index, + ) + if data: + data = self.reformat_data(data) + else: + data = {'Total': 0} + all_data.append([scenario, data]) + self.cache[cache_key] = data + self.combobox_menu.scenario.setCurrentIndex(orig_idx) + df = self.data_to_df(all_data, compare) + # TODO manage empty dataframe so overall calculation doesn't fail with plot generation + return df + + def calculate_contributions(self, demand, demand_key, + method, method_index: int = None, + scenario_lca: bool = False, scenario_index: int = None) -> Optional[dict]: + """Calculate LCA, do graph traversal on the first level of activities.""" + technosphere = bw.get_activity(demand_key).technosphere() + + max_calc = len([exch for exch in technosphere if + exch.input.key != exch.output.key]) # get the amount of exchanges that are not to self + + with warnings.catch_warnings(): + # Ignore the calculation count warning, as we don't care about it in this situation + warnings.filterwarnings("ignore", message="Stopping traversal due to calculation count.") + try: + if scenario_lca: + self.parent.mlca.update_lca_calculation_for_sankey(scenario_index, demand, method_index) + data = GraphTraversalWithScenario(self.parent.mlca).calculate(demand, method, cutoff=0, + max_calc=max_calc) + else: + data = bw.GraphTraversal().calculate(demand, method, cutoff=0, max_calc=max_calc) + except (ValueError, ZeroDivisionError) as e: + log.info('{}, no results calculated for {}'.format(e, method)) + return + return data + + def reformat_data(self, data: dict) -> dict: + """Reformat the data into a useable format.""" + lca = data["lca"] + demand = list(lca.demand.items())[0] + demand_key = list(lca.demand.keys())[0].key + total = lca.score + reverse_activity_dict = {v: k for k, v in lca.activity_dict.items()} + + # get the impact data per key + contributions = { + bw.get_activity(reverse_activity_dict[edge['from']]).key: edge['impact'] for edge in data["edges"] + if all(i != -1 for i in (edge["from"], edge["to"])) + } + # get impact for the total and demand + diff = total - sum([v for v in contributions.values()]) # for the demand process, calculate by the difference + contributions[demand_key] = diff + contributions['Total'] = total + return contributions + + def data_to_df(self, all_data: List[Tuple[object, dict]], compare: str) -> pd.DataFrame: + """Convert the provided data into a dataframe.""" + unique_keys = [] + # get all the unique keys: + d = {'index': ['Total'], 'reference product': [''], 'name': [''], + 'location': [''], 'unit': [''], 'database': ['']} + meta_cols = set(d.keys()) + for i, (item, data) in enumerate(all_data): + print('++ COL:', item, data) + unique_keys += list(data.keys()) + # already add the total with right column formatting depending on compares + if compare == 'Reference Flows': + col_name = self.metadata_to_index(self.key_to_metadata(item)) + elif compare == 'Impact Categories': + col_name = self.metadata_to_index(list(item)) + elif compare == 'Scenarios': + col_name = item + + self.cache['totals'][col_name] = data['Total'] + if self.relative: + d[col_name] = [1] + else: + d[col_name] = [data['Total']] + + all_data[i] = item, data, col_name + unique_keys = set(unique_keys) + + # convert to dict format to feed into dataframe + for key in unique_keys: + if key == 'Total': + continue + # get metadata + metadata = self.key_to_metadata(key) + d['index'].append(self.metadata_to_index(metadata)) + d['reference product'].append(metadata[0]) + d['name'].append(metadata[1]) + d['location'].append(metadata[2]) + d['unit'].append(metadata[3]) + d['database'].append(metadata[4]) + # check for each dataset if we have values, otherwise add np.nan + for item, data, col_name in all_data: + if val := data.get(key, False): + if self.relative: + value = val / self.cache['totals'][col_name] + else: + value = val + else: + value = np.nan + d[col_name].append(value) + df = pd.DataFrame(d) + check_cols = list(set(df.columns) - meta_cols) + df = df.dropna(subset=check_cols, how='all') + df.sort_values(by=col_name, ascending=False) # temporary sorting solution, just sort on the last column. + return df + + def key_to_metadata(self, key: tuple) -> list: + """Convert the key information to list with metadata. + + format: + [reference product, activity name, location, unit, database] + """ + act = bw.get_activity(key) + return [act.get('reference product'), act.get('name'), act.get('location'), act.get('unit'), key[0]] + + def metadata_to_index(self, data: list) -> str: + """Convert list to formatted index. + + format: + reference product | activity name | location | unit | database + """ + return ' | '.join(data) + + class CorrelationsTab(NewAnalysisTab): def __init__(self, parent): super().__init__(parent) From 762cf6c8e8139806b90ba75f78b88d24a29f4849 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 11 Jan 2024 20:51:50 +0100 Subject: [PATCH 3/8] Fix cache_key similar to #1180 --- .../layouts/tabs/LCA_results_tabs.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/activity_browser/layouts/tabs/LCA_results_tabs.py b/activity_browser/layouts/tabs/LCA_results_tabs.py index e09f71ff4..655363ab5 100644 --- a/activity_browser/layouts/tabs/LCA_results_tabs.py +++ b/activity_browser/layouts/tabs/LCA_results_tabs.py @@ -1117,8 +1117,9 @@ def update_dataframe(self, *args, **kwargs): # run the analysis for every reference flow for demand_index, demand in enumerate(self.func_units): demand_key = self.func_keys[demand_index] - cache_key = (demand_key, method_index, scenario_index) + cache_key = (demand_index, method_index, scenario_index) if self.caching and self.cache.get(cache_key, False): + # this data is cached all_data.append([demand_key, self.cache[cache_key]]) continue data = self.calculate_contributions(demand, demand_key, @@ -1134,8 +1135,9 @@ def update_dataframe(self, *args, **kwargs): elif compare == 'Impact Categories': # run the analysis for every method for method_index, method in enumerate(self.methods): - cache_key = (demand_key, method_index, scenario_index) + cache_key = (demand_index, method_index, scenario_index) if self.caching and self.cache.get(cache_key, False): + # this data is cached all_data.append([method, self.cache[cache_key]]) continue data = self.calculate_contributions(demand, demand_key, @@ -1155,8 +1157,9 @@ def update_dataframe(self, *args, **kwargs): self.combobox_menu.scenario.setCurrentIndex(scenario_index) scenario = self.combobox_menu.scenario.currentText() - cache_key = (demand_key, method_index, scenario_index) + cache_key = (demand_index, method_index, scenario_index) if self.caching and self.cache.get(cache_key, False): + # this data is cached all_data.append([scenario, self.cache[cache_key]]) continue data = self.calculate_contributions(demand, demand_key, @@ -1184,10 +1187,14 @@ def calculate_contributions(self, demand, demand_key, exch.input.key != exch.output.key]) # get the amount of exchanges that are not to self with warnings.catch_warnings(): - # Ignore the calculation count warning, as we don't care about it in this situation + # ignore the calculation count warning, as we will hit it by design warnings.filterwarnings("ignore", message="Stopping traversal due to calculation count.") try: if scenario_lca: + #TODO review + # https://github.com/LCA-ActivityBrowser/activity-browser/pull/1180 + # https://github.com/LCA-ActivityBrowser/activity-browser/pull/1117 + # as there was a problem with getting the right method from the scenario object?? self.parent.mlca.update_lca_calculation_for_sankey(scenario_index, demand, method_index) data = GraphTraversalWithScenario(self.parent.mlca).calculate(demand, method, cutoff=0, max_calc=max_calc) @@ -1269,6 +1276,7 @@ def data_to_df(self, all_data: List[Tuple[object, dict]], compare: str) -> pd.Da df = pd.DataFrame(d) check_cols = list(set(df.columns) - meta_cols) df = df.dropna(subset=check_cols, how='all') + # TODO sort like https://github.com/LCA-ActivityBrowser/activity-browser/issues/887 df.sort_values(by=col_name, ascending=False) # temporary sorting solution, just sort on the last column. return df From 5f66b0c685bb94eabf3c4172bfa517c230440288 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 22 Feb 2024 17:14:05 +0100 Subject: [PATCH 4/8] re-add product tab to Tabs tuple --- activity_browser/layouts/tabs/LCA_results_tabs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activity_browser/layouts/tabs/LCA_results_tabs.py b/activity_browser/layouts/tabs/LCA_results_tabs.py index 1eb04905b..4e093dcae 100644 --- a/activity_browser/layouts/tabs/LCA_results_tabs.py +++ b/activity_browser/layouts/tabs/LCA_results_tabs.py @@ -74,7 +74,7 @@ def get_unit(method: tuple, relative: bool = False) -> str: # Special namedtuple for the LCAResults TabWidget. Tabs = namedtuple( - "tabs", ("inventory", "results", "ef", "process", "sankey", "mc", "gsa") + "tabs", ("inventory", "results", "ef", "process", "product", "sankey", "mc", "gsa") ) Relativity = namedtuple("relativity", ("relative", "absolute")) ExportTable = namedtuple("export_table", ("label", "copy", "csv", "excel")) From 1e8e833648e5379c28ba014239d09a35e1d1a6d0 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 19 Sep 2024 16:40:02 +0200 Subject: [PATCH 5/8] Update strings --- .../layouts/tabs/LCA_results_tabs.py | 115 +++++++++--------- 1 file changed, 58 insertions(+), 57 deletions(-) diff --git a/activity_browser/layouts/tabs/LCA_results_tabs.py b/activity_browser/layouts/tabs/LCA_results_tabs.py index ff089ad83..11d5e07bc 100644 --- a/activity_browser/layouts/tabs/LCA_results_tabs.py +++ b/activity_browser/layouts/tabs/LCA_results_tabs.py @@ -1193,14 +1193,14 @@ class ProductContributionsTab(ContributionTab): def __init__(self, cs_name, parent=None): super().__init__(parent) - self.cache = {'totals': {}} # We cache the calculated data, as it can take some time to generate. + self.cache = {"totals": {}} # We cache the calculated data, as it can take some time to generate. # We cache the individual calculation results, as they are re-used in multiple views # e.g. FU1 x method1 x scenario1 # may be seen in both 'Reference Flows' and 'Impact Categories', just with different axes. # we also cache totals, not for calculation speed, but to be able to easily convert for relative results self.caching = True # set to False to disable caching for debug - self.layout.addLayout(get_header_layout('Product Contributions')) + self.layout.addLayout(get_header_layout("Product Contributions")) combobox = self.build_combobox(has_method=True, has_func=True) self.layout.addLayout(combobox) self.layout.addWidget(horizontal_line()) @@ -1209,15 +1209,15 @@ def __init__(self, cs_name, parent=None): # get relevant data from calculation setup self.cs = cs_name - func_units = bw.calculation_setups[self.cs]['inv'] + func_units = bw.calculation_setups[self.cs]["inv"] self.func_keys = [list(fu.keys())[0] for fu in func_units] # extract a list of keys from the functional units self.func_units = [ {bw.get_activity(k): v for k, v in fu.items()} for fu in func_units ] - self.methods = bw.calculation_setups[self.cs]['ia'] + self.methods = bw.calculation_setups[self.cs]["ia"] - self.contribution_fn = 'Product contributions' + self.contribution_fn = "Product contributions" self.switches.configure(self.has_func, self.has_method) self.connect_signals() self.toggle_comparisons(self.switches.indexes.func) @@ -1227,6 +1227,18 @@ def __init__(self, cs_name, parent=None): def update_dataframe(self, *args, **kwargs): """Retrieve the product contributions.""" + + def get_data(): + _data = self.calculate_contributions(demand, demand_key, + method=method, method_index=method_index, + scenario_lca=self.has_scenarios, scenario_index=scenario_index, + ) + if _data: + _data = self.reformat_data(_data) + else: + _data = {"Total": 0} + return _data + # get the right data if self.has_scenarios: scenario_index = self.combobox_menu.scenario.currentIndex() @@ -1240,7 +1252,7 @@ def update_dataframe(self, *args, **kwargs): all_data = [] compare = self.switches.currentText() - if compare == 'Reference Flows': + if compare == "Reference Flows": # run the analysis for every reference flow for demand_index, demand in enumerate(self.func_units): demand_key = self.func_keys[demand_index] @@ -1249,17 +1261,12 @@ def update_dataframe(self, *args, **kwargs): # this data is cached all_data.append([demand_key, self.cache[cache_key]]) continue - data = self.calculate_contributions(demand, demand_key, - method=method, method_index=method_index, - scenario_lca=self.has_scenarios, scenario_index=scenario_index, - ) - if data: - data = self.reformat_data(data) - else: - data = {'Total': 0} + + data = get_data() all_data.append([demand_key, data]) - self.cache[cache_key] = data - elif compare == 'Impact Categories': + if self.caching: + self.cache[cache_key] = data + elif compare == "Impact Categories": # run the analysis for every method for method_index, method in enumerate(self.methods): cache_key = (demand_index, method_index, scenario_index) @@ -1267,17 +1274,12 @@ def update_dataframe(self, *args, **kwargs): # this data is cached all_data.append([method, self.cache[cache_key]]) continue - data = self.calculate_contributions(demand, demand_key, - method=method, method_index=method_index, - scenario_lca=self.has_scenarios, scenario_index=scenario_index, - ) - if data: - data = self.reformat_data(data) - else: - data = {'Total': 0} + + data = get_data() all_data.append([method, data]) - self.cache[cache_key] = data - elif compare == 'Scenarios': + if self.caching: + self.cache[cache_key] = data + elif compare == "Scenarios": # run the analysis for every scenario orig_idx = self.combobox_menu.scenario.currentIndex() for scenario_index in range(self.combobox_menu.scenario.count()): @@ -1289,18 +1291,17 @@ def update_dataframe(self, *args, **kwargs): # this data is cached all_data.append([scenario, self.cache[cache_key]]) continue - data = self.calculate_contributions(demand, demand_key, - method=method, method_index=method_index, - scenario_lca=self.has_scenarios, scenario_index=scenario_index, - ) - if data: - data = self.reformat_data(data) - else: - data = {'Total': 0} + + data = get_data() all_data.append([scenario, data]) - self.cache[cache_key] = data + if self.caching: + self.cache[cache_key] = data self.combobox_menu.scenario.setCurrentIndex(orig_idx) + df = self.data_to_df(all_data, compare) + # print("+++") + # for _, row in df.iterrows(): + # print([c for c in row]) # TODO manage empty dataframe so overall calculation doesn't fail with plot generation return df @@ -1328,7 +1329,7 @@ def calculate_contributions(self, demand, demand_key, else: data = bw.GraphTraversal().calculate(demand, method, cutoff=0, max_calc=max_calc) except (ValueError, ZeroDivisionError) as e: - log.info('{}, no results calculated for {}'.format(e, method)) + log.info(f"{e}, no Product Contribution results calculated for {demand} | {method}") return return data @@ -1342,59 +1343,59 @@ def reformat_data(self, data: dict) -> dict: # get the impact data per key contributions = { - bw.get_activity(reverse_activity_dict[edge['from']]).key: edge['impact'] for edge in data["edges"] + bw.get_activity(reverse_activity_dict[edge["from"]]).key: edge["impact"] for edge in data["edges"] if all(i != -1 for i in (edge["from"], edge["to"])) } # get impact for the total and demand diff = total - sum([v for v in contributions.values()]) # for the demand process, calculate by the difference contributions[demand_key] = diff - contributions['Total'] = total + contributions["Total"] = total return contributions def data_to_df(self, all_data: List[Tuple[object, dict]], compare: str) -> pd.DataFrame: """Convert the provided data into a dataframe.""" unique_keys = [] # get all the unique keys: - d = {'index': ['Total'], 'reference product': [''], 'name': [''], - 'location': [''], 'unit': [''], 'database': ['']} + d = {"index": ["Total"], "reference product": [""], "name": [""], + "location": [""], "unit": [""], "database": [""]} meta_cols = set(d.keys()) for i, (item, data) in enumerate(all_data): - print('++ COL:', item, data) + print("++ COL:", item, data) unique_keys += list(data.keys()) # already add the total with right column formatting depending on compares - if compare == 'Reference Flows': + if compare == "Reference Flows": col_name = self.metadata_to_index(self.key_to_metadata(item)) - elif compare == 'Impact Categories': + elif compare == "Impact Categories": col_name = self.metadata_to_index(list(item)) - elif compare == 'Scenarios': + elif compare == "Scenarios": col_name = item - self.cache['totals'][col_name] = data['Total'] + self.cache["totals"][col_name] = data["Total"] if self.relative: d[col_name] = [1] else: - d[col_name] = [data['Total']] + d[col_name] = [data["Total"]] all_data[i] = item, data, col_name unique_keys = set(unique_keys) # convert to dict format to feed into dataframe for key in unique_keys: - if key == 'Total': + if key == "Total": continue # get metadata metadata = self.key_to_metadata(key) - d['index'].append(self.metadata_to_index(metadata)) - d['reference product'].append(metadata[0]) - d['name'].append(metadata[1]) - d['location'].append(metadata[2]) - d['unit'].append(metadata[3]) - d['database'].append(metadata[4]) + d["index"].append(self.metadata_to_index(metadata)) + d["reference product"].append(metadata[0]) + d["name"].append(metadata[1]) + d["location"].append(metadata[2]) + d["unit"].append(metadata[3]) + d["database"].append(metadata[4]) # check for each dataset if we have values, otherwise add np.nan for item, data, col_name in all_data: if val := data.get(key, False): if self.relative: - value = val / self.cache['totals'][col_name] + value = val / self.cache["totals"][col_name] else: value = val else: @@ -1402,7 +1403,7 @@ def data_to_df(self, all_data: List[Tuple[object, dict]], compare: str) -> pd.Da d[col_name].append(value) df = pd.DataFrame(d) check_cols = list(set(df.columns) - meta_cols) - df = df.dropna(subset=check_cols, how='all') + df = df.dropna(subset=check_cols, how="all") # TODO sort like https://github.com/LCA-ActivityBrowser/activity-browser/issues/887 df.sort_values(by=col_name, ascending=False) # temporary sorting solution, just sort on the last column. return df @@ -1414,7 +1415,7 @@ def key_to_metadata(self, key: tuple) -> list: [reference product, activity name, location, unit, database] """ act = bw.get_activity(key) - return [act.get('reference product'), act.get('name'), act.get('location'), act.get('unit'), key[0]] + return [act.get("reference product"), act.get("name"), act.get("location"), act.get("unit"), key[0]] def metadata_to_index(self, data: list) -> str: """Convert list to formatted index. @@ -1422,7 +1423,7 @@ def metadata_to_index(self, data: list) -> str: format: reference product | activity name | location | unit | database """ - return ' | '.join(data) + return " | ".join(data) class CorrelationsTab(NewAnalysisTab): From 7486f701869fc95709bca9f8731d0cd751d50a69 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Sun, 22 Sep 2024 21:41:41 +0200 Subject: [PATCH 6/8] refactor to use brightway directly instead of graph traversal code --- .../layouts/tabs/LCA_results_tabs.py | 107 ++++++++++-------- 1 file changed, 58 insertions(+), 49 deletions(-) diff --git a/activity_browser/layouts/tabs/LCA_results_tabs.py b/activity_browser/layouts/tabs/LCA_results_tabs.py index 11d5e07bc..f2464a571 100644 --- a/activity_browser/layouts/tabs/LCA_results_tabs.py +++ b/activity_browser/layouts/tabs/LCA_results_tabs.py @@ -5,6 +5,7 @@ """ from collections import namedtuple +from copy import deepcopy from typing import List, Tuple, Optional, Union from logging import getLogger @@ -1229,14 +1230,10 @@ def update_dataframe(self, *args, **kwargs): """Retrieve the product contributions.""" def get_data(): - _data = self.calculate_contributions(demand, demand_key, + _data = self.calculate_contributions(demand, demand_key, demand_index, method=method, method_index=method_index, scenario_lca=self.has_scenarios, scenario_index=scenario_index, ) - if _data: - _data = self.reformat_data(_data) - else: - _data = {"Total": 0} return _data # get the right data @@ -1299,58 +1296,70 @@ def get_data(): self.combobox_menu.scenario.setCurrentIndex(orig_idx) df = self.data_to_df(all_data, compare) - # print("+++") - # for _, row in df.iterrows(): - # print([c for c in row]) - # TODO manage empty dataframe so overall calculation doesn't fail with plot generation return df - def calculate_contributions(self, demand, demand_key, + def calculate_contributions(self, demand, demand_key, demand_index, method, method_index: int = None, scenario_lca: bool = False, scenario_index: int = None) -> Optional[dict]: - """Calculate LCA, do graph traversal on the first level of activities.""" + """TODO.""" + + # reuse LCA object from original calculation to skip 1 LCA + if scenario_lca: + # get lca object from mlca class + self.parent.mlca.update_lca_calculation_for_sankey(scenario_index, demand, method_index) + _lca = self.parent.mlca.lca + + # get score + score = self.parent.mlca.lca_scores[demand_index, method_index, scenario_index] + else: + # get lca object + set method + _lca = self.parent.mlca.lca + _lca.switch_method(method) + _lca.lcia_calculation() + + # get score + score = self.parent.mlca.lca_scores[demand_index, method_index] + + if score == 0: + # no need to calculate contributions to '0' score + # technically it could be that positive and negative score of same amount negate to 0, but highly unlikely. + return {"Total": 0, demand_key: 0} + + data = {"Total": score} + remainder = score # contribution of demand_key + + # get (potentially) contributing activities technosphere = bw.get_activity(demand_key).technosphere() - max_calc = len([exch for exch in technosphere if - exch.input.key != exch.output.key]) # get the amount of exchanges that are not to self + keys = [exch.input.key for exch in technosphere if + exch.input.key != exch.output.key] + # find scale from production amount and demand amount + scale = demand[demand_key] / [p for p in bw.get_activity(demand_key).production()][0].amount + + amounts = [exch.amount * scale for exch in technosphere if + exch.input.key != exch.output.key] + new_demand = {keys[i]: amounts[i] for i, _ in enumerate(keys)} + + # iterate over all activities demand_key is connected to + for key, amt in new_demand.items(): + if not scenario_lca: + # skip zero amounts, but only if not using scenarios (zero exchange could have been overwritten) + if amt == 0: + del demand[key] + continue - with warnings.catch_warnings(): - # ignore the calculation count warning, as we will hit it by design - warnings.filterwarnings("ignore", message="Stopping traversal due to calculation count.") - try: - if scenario_lca: - #TODO review - # https://github.com/LCA-ActivityBrowser/activity-browser/pull/1180 - # https://github.com/LCA-ActivityBrowser/activity-browser/pull/1117 - # as there was a problem with getting the right method from the scenario object?? - self.parent.mlca.update_lca_calculation_for_sankey(scenario_index, demand, method_index) - data = GraphTraversalWithScenario(self.parent.mlca).calculate(demand, method, cutoff=0, - max_calc=max_calc) - else: - data = bw.GraphTraversal().calculate(demand, method, cutoff=0, max_calc=max_calc) - except (ValueError, ZeroDivisionError) as e: - log.info(f"{e}, no Product Contribution results calculated for {demand} | {method}") - return - return data + # recalculate for this demand + _lca.redo_lci({key: amt}) + _lca.redo_lcia() - def reformat_data(self, data: dict) -> dict: - """Reformat the data into a useable format.""" - lca = data["lca"] - demand = list(lca.demand.items())[0] - demand_key = list(lca.demand.keys())[0].key - total = lca.score - reverse_activity_dict = {v: k for k, v in lca.activity_dict.items()} - - # get the impact data per key - contributions = { - bw.get_activity(reverse_activity_dict[edge["from"]]).key: edge["impact"] for edge in data["edges"] - if all(i != -1 for i in (edge["from"], edge["to"])) - } - # get impact for the total and demand - diff = total - sum([v for v in contributions.values()]) # for the demand process, calculate by the difference - contributions[demand_key] = diff - contributions["Total"] = total - return contributions + score = _lca.score + if score != 0: + # only store non-zero results + data[key] = score + remainder -= score + + data[demand_key] = remainder + return data def data_to_df(self, all_data: List[Tuple[object, dict]], compare: str) -> pd.DataFrame: """Convert the provided data into a dataframe.""" From d90dddc57cdb38954bfb14d395b087fa23f518ea Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Mon, 23 Sep 2024 21:49:34 +0200 Subject: [PATCH 7/8] minor changes + setup TODO --- .../layouts/tabs/LCA_results_tabs.py | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/activity_browser/layouts/tabs/LCA_results_tabs.py b/activity_browser/layouts/tabs/LCA_results_tabs.py index f2464a571..73c52a946 100644 --- a/activity_browser/layouts/tabs/LCA_results_tabs.py +++ b/activity_browser/layouts/tabs/LCA_results_tabs.py @@ -20,6 +20,9 @@ QPushButton, QRadioButton, QScrollArea, QTableView, QTabWidget, QToolBar, QVBoxLayout, QWidget) +from activity_browser.bwutils import AB_metadata + +from activity_browser.bwutils.metadata import MetaDataStore from stats_arrays.errors import InvalidParamsError import brightway2 as bw @@ -1202,6 +1205,8 @@ def __init__(self, cs_name, parent=None): self.caching = True # set to False to disable caching for debug self.layout.addLayout(get_header_layout("Product Contributions")) + self.layout.addWidget(self.cutoff_menu) + self.layout.addWidget(horizontal_line()) combobox = self.build_combobox(has_method=True, has_func=True) self.layout.addLayout(combobox) self.layout.addWidget(horizontal_line()) @@ -1223,18 +1228,22 @@ def __init__(self, cs_name, parent=None): self.connect_signals() self.toggle_comparisons(self.switches.indexes.func) - self.combobox_menu.agg_label.setVisible(False) - self.combobox_menu.agg.setVisible(False) - def update_dataframe(self, *args, **kwargs): """Retrieve the product contributions.""" + #TODO + # 0 make this work with scenarios + # in case scenarios are used, we need to read the amounts from the matrix, amounts may have changed + # 1 refactor so this updates the df, not does calculations/cache reads etc + # 2 figure out how this already works with relative??? + # 3 make this work with aggegator for data + # 4 make this work with cutoff menu (limit, limit_type (and normalize??) + def get_data(): - _data = self.calculate_contributions(demand, demand_key, demand_index, - method=method, method_index=method_index, - scenario_lca=self.has_scenarios, scenario_index=scenario_index, - ) - return _data + return self.calculate_contributions(demand, demand_key, demand_index, + method=method, method_index=method_index, + scenario_lca=self.has_scenarios, scenario_index=scenario_index, + ) # get the right data if self.has_scenarios: @@ -1343,7 +1352,7 @@ def calculate_contributions(self, demand, demand_key, demand_index, # iterate over all activities demand_key is connected to for key, amt in new_demand.items(): if not scenario_lca: - # skip zero amounts, but only if not using scenarios (zero exchange could have been overwritten) + # skip zero amounts, but only if not using scenarios (zero exchange could have been overwritten in scen) if amt == 0: del demand[key] continue @@ -1356,7 +1365,7 @@ def calculate_contributions(self, demand, demand_key, demand_index, if score != 0: # only store non-zero results data[key] = score - remainder -= score + remainder -= score # subtract this from remainder data[demand_key] = remainder return data @@ -1369,7 +1378,6 @@ def data_to_df(self, all_data: List[Tuple[object, dict]], compare: str) -> pd.Da "location": [""], "unit": [""], "database": [""]} meta_cols = set(d.keys()) for i, (item, data) in enumerate(all_data): - print("++ COL:", item, data) unique_keys += list(data.keys()) # already add the total with right column formatting depending on compares if compare == "Reference Flows": @@ -1423,8 +1431,7 @@ def key_to_metadata(self, key: tuple) -> list: format: [reference product, activity name, location, unit, database] """ - act = bw.get_activity(key) - return [act.get("reference product"), act.get("name"), act.get("location"), act.get("unit"), key[0]] + return list(AB_metadata.get_metadata([key], ["reference product", "name", "location", "unit"]).iloc[0]) + [key[0]] def metadata_to_index(self, data: list) -> str: """Convert list to formatted index. @@ -1434,6 +1441,14 @@ def metadata_to_index(self, data: list) -> str: """ return " | ".join(data) + def build_combobox( + self, has_method: bool = True, has_func: bool = False + ) -> QHBoxLayout: + self.combobox_menu.agg.addItems( + self.parent.contributions.DEFAULT_ACT_AGGREGATES + ) + return super().build_combobox(has_method, has_func) + class CorrelationsTab(NewAnalysisTab): def __init__(self, parent): From ffbd4036155a40334e41cb96bc20090f58456698 Mon Sep 17 00:00:00 2001 From: marc-vdm Date: Thu, 26 Sep 2024 23:04:11 +0200 Subject: [PATCH 8/8] Enable scenarios for product contributions --- .../layouts/tabs/LCA_results_tabs.py | 97 ++++++++++++------- 1 file changed, 62 insertions(+), 35 deletions(-) diff --git a/activity_browser/layouts/tabs/LCA_results_tabs.py b/activity_browser/layouts/tabs/LCA_results_tabs.py index 73c52a946..321ac0b95 100644 --- a/activity_browser/layouts/tabs/LCA_results_tabs.py +++ b/activity_browser/layouts/tabs/LCA_results_tabs.py @@ -920,6 +920,7 @@ def set_filename(self, optional_fields: dict = None): optional.get("functional_unit"), self.unit, ) + filename = "_".join((str(x) for x in fields if x is not None)) self.plot.plot_name, self.table.table_name = filename, filename @@ -992,12 +993,9 @@ def set_combobox_changes(self): # gather the combobox values method = self.parent.method_dict[self.combobox_menu.method.currentText()] functional_unit = self.combobox_menu.func.currentText() - scenario = self.combobox_menu.scenario.currentIndex() + scenario = max(self.combobox_menu.scenario.currentIndex(), 0) # set scenario 0 if not initiated yet aggregator = self.combobox_menu.agg.currentText() - # catch uninitiated scenario combobox - if scenario < 0: - scenario = 0 # set aggregator to None if unwanted if aggregator == "none": aggregator = None @@ -1232,12 +1230,11 @@ def update_dataframe(self, *args, **kwargs): """Retrieve the product contributions.""" #TODO - # 0 make this work with scenarios - # in case scenarios are used, we need to read the amounts from the matrix, amounts may have changed # 1 refactor so this updates the df, not does calculations/cache reads etc # 2 figure out how this already works with relative??? # 3 make this work with aggegator for data # 4 make this work with cutoff menu (limit, limit_type (and normalize??) + # 5 update documentation def get_data(): return self.calculate_contributions(demand, demand_key, demand_index, @@ -1247,7 +1244,8 @@ def get_data(): # get the right data if self.has_scenarios: - scenario_index = self.combobox_menu.scenario.currentIndex() + # get the scenario index, if it is -1 (none selected), then use index 0 + scenario_index = max(self.combobox_menu.scenario.currentIndex(), 0) else: scenario_index = None method_index = self.combobox_menu.method.currentIndex() @@ -1289,9 +1287,7 @@ def get_data(): # run the analysis for every scenario orig_idx = self.combobox_menu.scenario.currentIndex() for scenario_index in range(self.combobox_menu.scenario.count()): - self.combobox_menu.scenario.setCurrentIndex(scenario_index) - scenario = self.combobox_menu.scenario.currentText() - + scenario = self.combobox_menu.scenario.itemText(scenario_index) cache_key = (demand_index, method_index, scenario_index) if self.caching and self.cache.get(cache_key, False): # this data is cached @@ -1312,22 +1308,65 @@ def calculate_contributions(self, demand, demand_key, demand_index, scenario_lca: bool = False, scenario_index: int = None) -> Optional[dict]: """TODO.""" + def get_default_demands() -> dict: + """Get the inputs to calculate contributions from the activity""" + # get exchange keys leading to this activity + technosphere = bw.get_activity(demand_key).technosphere() + + keys = [exch.input.key for exch in technosphere if + exch.input.key != exch.output.key] + # find scale from production amount and demand amount + scale = demand[demand_key] / [p for p in bw.get_activity(demand_key).production()][0].amount + + amounts = [exch.amount * scale for exch in technosphere if + exch.input.key != exch.output.key] + demands = {keys[i]: amounts[i] for i, _ in enumerate(keys)} + return demands + + def get_scenario_demands() -> dict: + """Get the inputs to calculate contributions from the scenario matrix""" + # get exchange keys leading to this activity + technosphere = bw.get_activity(demand_key).technosphere() + demand_idx = _lca.product_dict[demand_key] + + keys = [exch.input.key for exch in technosphere if + exch.input.key != exch.output.key] + # find scale from production amount and demand amount + scale = demand[demand_key] / _lca.technosphere_matrix[_lca.activity_dict[demand_key], demand_idx] * -1 + + amounts = [] + + for exch in technosphere: + exch_idx = _lca.activity_dict[exch.input.key] + if exch.input.key != exch.output.key: + amounts.append(_lca.technosphere_matrix[exch_idx, demand_idx] * scale) + + # write al non-zero exchanges to demand dict + demands = {keys[i]: amounts[i] for i, _ in enumerate(keys) if amounts[i] != 0} + return demands + # reuse LCA object from original calculation to skip 1 LCA if scenario_lca: + # get score from the already calculated result + score = self.parent.mlca.lca_scores[demand_index, method_index, scenario_index] + # get lca object from mlca class - self.parent.mlca.update_lca_calculation_for_sankey(scenario_index, demand, method_index) + self.parent.mlca.current = scenario_index + self.parent.mlca.update_matrices() _lca = self.parent.mlca.lca + _lca.redo_lci(demand) - # get score - score = self.parent.mlca.lca_scores[demand_index, method_index, scenario_index] + # _lca.lci(factorize=True) else: - # get lca object + set method + # get score from the already calculated result + score = self.parent.mlca.lca_scores[demand_index, method_index] + + # get lca object to calculate new results _lca = self.parent.mlca.lca - _lca.switch_method(method) - _lca.lcia_calculation() - # get score - score = self.parent.mlca.lca_scores[demand_index, method_index] + # set the correct method + _lca.switch_method(method) + _lca.lcia_calculation() if score == 0: # no need to calculate contributions to '0' score @@ -1337,25 +1376,13 @@ def calculate_contributions(self, demand, demand_key, demand_index, data = {"Total": score} remainder = score # contribution of demand_key - # get (potentially) contributing activities - technosphere = bw.get_activity(demand_key).technosphere() - - keys = [exch.input.key for exch in technosphere if - exch.input.key != exch.output.key] - # find scale from production amount and demand amount - scale = demand[demand_key] / [p for p in bw.get_activity(demand_key).production()][0].amount - - amounts = [exch.amount * scale for exch in technosphere if - exch.input.key != exch.output.key] - new_demand = {keys[i]: amounts[i] for i, _ in enumerate(keys)} + if not scenario_lca: + new_demands = get_default_demands() + else: + new_demands = get_scenario_demands() # iterate over all activities demand_key is connected to - for key, amt in new_demand.items(): - if not scenario_lca: - # skip zero amounts, but only if not using scenarios (zero exchange could have been overwritten in scen) - if amt == 0: - del demand[key] - continue + for key, amt in new_demands.items(): # recalculate for this demand _lca.redo_lci({key: amt})