Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,20 @@ A unit process or transforming activity in a database. In the UI this is often o

A flow between activities (processes) (technosphere) or from/to biosphere flows, with an amount and optional uncertainty/parameters. In signals, related updates may appear as **edge**. TBD: AB-wide exchanges should be renamed to flows.

### Functional flow

The exchange that expresses the purpose of a process: either a **product** output or a **waste** input. The reverse combinations (product input, waste output) are non-functional flows. Further concepts (e.g. allocation) are defined in the `bw-functional` / functional_sqlite code and documentation.
_Avoid_: reference flow (when meaning the process’s function rather than the LCA study’s functional unit)

### Product / reference product

The output of an activity that can be used as a functional flow in a calculation setup. Multifunctional activities may have multiple products; The functional_sqlite (bw-functional) backend distinguishes products and wastes. Product-outputs and waste-inputs are funcional flows. The revers combinations are non-functional flows. Further concepts (e.g. allocation) are defined in the `bw-functional` / functional_sqlite code and documentation.
A functional output of a process; can be used as a functional unit in a calculation setup. Multifunctional activities may have multiple products. On functional_sqlite, products are distinct nodes (`type=product`). On sqlite, a `processwithreferenceproduct` whose production amount is non-negative plays this role.
_Avoid_: output (alone), good

### Waste

A functional input of a process (waste treatment): the process exists to take in that waste. On functional_sqlite, wastes are distinct nodes (`type=waste`). On sqlite, a `processwithreferenceproduct` whose production amount is negative plays this role.
_Avoid_: waste treatment (when meaning the flow itself rather than the treating process)

### Biosphere / elementary flow

Expand Down
3 changes: 3 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@ recursive-include activity_browser *.css
recursive-include activity_browser *.txt
recursive-include activity_browser *.zip
recursive-include activity_browser *.ttf
recursive-include activity_browser *.xlsx
recursive-include activity_browser *.csv
recursive-include activity_browser *.md
51 changes: 21 additions & 30 deletions activity_browser/app/pages/activity_details/exchanges_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from activity_browser import app
from activity_browser.bwutils.commontasks import (refresh_node, database_is_locked, database_is_legacy,
is_node_product_or_waste, is_node_biosphere, parameters_in_scope,
is_node_product, is_node_waste)
is_node_product, is_node_waste, get_exchange_type)
from activity_browser.bwutils.uncertainty import uncertainty_cell_summary
from activity_browser.ui import widgets, icons, delegates, core

Expand Down Expand Up @@ -261,8 +261,8 @@ def dragEnterEvent(self, event):
return

if action == "waste":
self.output_view.overlay.setText("Drop to produce waste")
self.input_view.overlay.setText("Drop to substitute waste consumption")
self.output_view.overlay.setText("Drop to add waste treatment")
self.input_view.overlay.setText("Drop to substitute waste treatment")
return

if action == "resource":
Expand Down Expand Up @@ -356,35 +356,26 @@ def action_from_mime(self, mime: core.ABMimeData) -> Literal["product", "waste",

"""
keys = mime.retrievePickleData("application/bw-nodekeylist")
data = app.metadata.get_metadata(keys, ["type"])
data = set(data["type"].unique())
data.discard("process")
data.discard("multifunctional")
data.discard("nonfunctional")

if len(data) != 1:
return "generic"
actions: set[str] = set()
for key in keys:
if is_node_waste(key):
actions.add("waste")
elif is_node_product(key):
actions.add("product")
elif is_node_biosphere(key):
node_type = refresh_node(key)._document.type
if node_type == "natural resource":
actions.add("resource")
elif node_type == "emission":
actions.add("emission")
else:
actions.add("generic")
else:
actions.add("generic")

node_type = data.pop()
if node_type in ["product", "processwithreferenceproduct"]:
return "product"
if node_type == "waste":
return "waste"
if node_type == "natural resource":
return "resource"
if node_type == "emission":
return "emission"
else:
if len(actions) != 1:
return "generic"

def get_exchange_type(activity_key: tuple, output=False) -> str | None:
if is_node_product(activity_key):
return "substitution" if output else "technosphere"
if is_node_waste(activity_key):
return "-technosphere" if output else "-substitution"
elif is_node_biosphere(activity_key):
return "biosphere"
return None
return actions.pop() # type: ignore[return-value]


class RelinkDelegate(delegates.StringDelegate):
Expand Down
2 changes: 1 addition & 1 deletion activity_browser/app/pages/activity_details/graph_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from activity_browser import static, app
from activity_browser.bwutils.commontasks import refresh_node, database_is_locked
from activity_browser.ui import widgets
from .exchanges_tab import get_exchange_type
from activity_browser.bwutils.commontasks import get_exchange_type



Expand Down
192 changes: 177 additions & 15 deletions activity_browser/app/pages/calculation_setup/scenario_section.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
import pandas as pd
import bw2data as bd
from activity_browser.bwutils import superstructure as ss
from activity_browser.bwutils.superstructure import scenario_templates as scen_tpl

from activity_browser import app
from activity_browser.bwutils import calculation_setup as cs_helpers
from activity_browser.bwutils.superstructure import inclusion as scen_inc
from activity_browser.ui import icons, widgets, core, delegates

from activity_browser.ui.icons import qicons


class ScenarioSection(QtWidgets.QWidget):
Expand All @@ -31,22 +32,37 @@ def __init__(self, parent=None):
self._last_inclusion_mismatch = False

# set up the control buttons
self.get_template_btn = app.actions.SaveParametersToExcel.get_QButton()
self.get_template_btn.setText("Parameter template...")

self.table_btn = QtWidgets.QPushButton("Add scenarios...", self)

self.save_scenario = QtWidgets.QPushButton("Save to file...", self)
self.get_template_btn = QtWidgets.QPushButton("Template...", self)
self.get_template_btn.setToolTip(
"Download a parameter- or flow-scenario starter file (.xlsx or .csv)"
)
self.save_scenario = QtWidgets.QPushButton("Save...", self)
self.save_scenario.setToolTip(
"Save the loaded scenarios to a flow-scenarios file"
)
self.save_scenario.setDisabled(True)

self.help_btn = QtWidgets.QToolButton(self)
self.help_btn.setIcon(qicons.question)
self.help_btn.setAutoRaise(True)
self.help_btn.setToolButtonStyle(Qt.ToolButtonIconOnly)
self.help_btn.setToolTip("Help: scenario modeling in Activity Browser")

# set up the combination buttons

# initiate the combine scenarios button
self.product_choice = QtWidgets.QRadioButton("Combine scenarios", self)
self.product_choice.setChecked(True)
self.product_choice.setToolTip(
"Build all or selected combinations of scenarios from the loaded files"
)

# initiate the extend scenarios button
self.addition_choice = QtWidgets.QRadioButton("Extend scenarios", self)
self.addition_choice.setToolTip(
"Extend files on shared scenario names (only works for matching scenario names across files)"
)

# group them and make them exclusive
self.combine_group = QtWidgets.QButtonGroup(self)
Expand All @@ -72,10 +88,11 @@ def __init__(self, parent=None):

tool_row.addWidget(widgets.ABLabel.demiBold(" Scenarios:", self))
tool_row.addStretch()
tool_row.addWidget(self.get_template_btn)
tool_row.addWidget(self.table_btn)
tool_row.addWidget(self.get_template_btn)
tool_row.addWidget(self.save_scenario)
tool_row.addWidget(self.group_box)
tool_row.addWidget(self.help_btn)

# layout for the different scenario tables that can be added
self.scenario_tables = QtWidgets.QHBoxLayout()
Expand Down Expand Up @@ -111,9 +128,51 @@ def connect_signals(self) -> None:

self.table_btn.clicked.connect(self.add_table)
self.table_btn.clicked.connect(self.can_add_table)
self.get_template_btn.clicked.connect(self.get_template_action)
self.save_scenario.clicked.connect(self.save_action)
self.help_btn.clicked.connect(self.show_scenarios_help)
self.combine_group.buttonClicked.connect(self.toggle_combine_type)

def get_template_action(self) -> None:
"""Save a parameter- or flow-scenario starter template chosen by the user."""
dialog = GetScenarioTemplateDialog(self)
if dialog.exec_() != QtWidgets.QDialog.Accepted:
return
kind, fmt = dialog.selection()
default_name = (
f"parameter-scenarios.{fmt}" if kind == "parameter" else f"flow-scenarios.{fmt}"
)
if fmt == "xlsx":
file_filter = "Excel (*.xlsx)"
else:
file_filter = "CSV (*.csv)"
path, _ = QtWidgets.QFileDialog.getSaveFileName(
parent=self,
caption="Save scenario template",
dir=str(Path.home() / default_name),
filter=file_filter,
)
if not path:
return
dest = Path(path)
try:
if kind == "flow" or not scen_tpl.project_has_parameters():
scen_tpl.copy_scenario_template(kind, fmt, dest)
else:
if dest.suffix.lower() not in {".xlsx", ".xls", ".csv"}:
dest = dest.with_suffix(f".{fmt}")
scen_tpl.write_parameter_template(dest)
except Exception:
logger.exception("Failed to write scenario template to {}", dest)
QtWidgets.QMessageBox.critical(
self,
"Could not save template",
f"Failed to save the scenario template to:\n{dest}",
)

def show_scenarios_help(self) -> None:
ScenariosHelpDialog(self).exec_()

def update_stats(self) -> None:
"""Update the statistics at the bottom of the widget"""
n_total = len(self._scenario_dataframe.columns)
Expand Down Expand Up @@ -369,6 +428,7 @@ def _clear_combined_scenarios(self) -> None:
self._mode = None
self.sync_combinations_panel()
self.update_stats()
self.refresh_save_button()

def combined_dataframe(self, skip_checks: bool = False) -> None:
"""Updates scenario dataframe to contain the combined scenarios of multiple tables."""
Expand All @@ -381,6 +441,7 @@ def combined_dataframe(self, skip_checks: bool = False) -> None:
manager = ss.SuperstructureManager(*data)
self._scenario_dataframe = manager.combined_data(kind, skip_checks)
self.reconcile_inclusion_after_combine()
self.refresh_save_button()

def add_table(self) -> None:
"""Add a new table widget to the widget and add to the list of tables"""
Expand All @@ -406,10 +467,7 @@ def remove_table(self, index: int) -> None:

# free up the memory
table_widget.deleteLater()

# update save_scenario button
if not self.tables:
self.save_scenario.setDisabled(True)
self.refresh_save_button()
self.updateGeometry()

def clear_tables(self) -> None:
Expand All @@ -418,7 +476,6 @@ def clear_tables(self) -> None:
self.scenario_tables.removeWidget(w)
w.deleteLater()
self.tables = []
self.save_scenario.setDisabled(True)
self.updateGeometry()
self.combined_dataframe()

Expand All @@ -437,6 +494,10 @@ def can_add_table(self) -> None:
"""
self.table_btn.setEnabled(len(self.tables) < self.max_tables)

def refresh_save_button(self) -> None:
"""Enable Save when a merged flow-scenario table is available."""
self.save_scenario.setEnabled(not self._scenario_dataframe.empty)

def save_action(self) -> None:
"""Creates and saves to file (.xlsx, or .csv) the scenario dataframe after the loaded scenarios have been
merged. Will not contain duplicates. Will not contain self-referential technosphere flows.
Expand Down Expand Up @@ -473,9 +534,8 @@ def save_action(self) -> None:
savedf.to_csv(filepath, index=False, sep=";")

def save_button(self, visible: bool):
self.save_scenario.setDisabled(not visible)
self.show()
self.updateGeometry()
"""Compatibility hook after manual file load; state follows the combined table."""
self.refresh_save_button()


class ScenarioCombinationsPanel(QtWidgets.QWidget):
Expand Down Expand Up @@ -1006,3 +1066,105 @@ def construct_dialog(cls, parent: QtWidgets.QWidget = None, options: list = None
obj.updateGeometry()
return obj


class GetScenarioTemplateDialog(QtWidgets.QDialog):
"""Choose parameter vs flow starter template and csv/xlsx format."""

def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Get scenario template")
self.setWindowIcon(qicons.question)

self.kind_group = QtWidgets.QButtonGroup(self)
self.parameter_radio = QtWidgets.QRadioButton("Parameter scenarios", self)
self.flow_radio = QtWidgets.QRadioButton("Flow scenarios", self)
self.parameter_radio.setChecked(True)
self.kind_group.addButton(self.parameter_radio)
self.kind_group.addButton(self.flow_radio)

kind_box = QtWidgets.QGroupBox("Template type", self)
kind_layout = QtWidgets.QVBoxLayout(kind_box)
kind_layout.addWidget(self.parameter_radio)
kind_layout.addWidget(self.flow_radio)

self.format_group = QtWidgets.QButtonGroup(self)
self.xlsx_radio = QtWidgets.QRadioButton("Excel (.xlsx)", self)
self.csv_radio = QtWidgets.QRadioButton("CSV (.csv)", self)
self.xlsx_radio.setChecked(True)
self.format_group.addButton(self.xlsx_radio)
self.format_group.addButton(self.csv_radio)

format_box = QtWidgets.QGroupBox("File format", self)
format_layout = QtWidgets.QVBoxLayout(format_box)
format_layout.addWidget(self.xlsx_radio)
format_layout.addWidget(self.csv_radio)

buttons = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel,
parent=self,
)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)

layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(kind_box)
layout.addWidget(format_box)
layout.addWidget(buttons)

def selection(self) -> tuple[str, str]:
kind = "parameter" if self.parameter_radio.isChecked() else "flow"
fmt = "xlsx" if self.xlsx_radio.isChecked() else "csv"
return kind, fmt


class ScenariosHelpDialog(QtWidgets.QDialog):
"""Compact explanation of scenario modeling in the calculation setup."""

def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Scenario modeling")
self.setWindowIcon(qicons.question)
self.resize(520, 420)

text = QtWidgets.QLabel(self)
text.setWordWrap(True)
text.setTextFormat(Qt.RichText)
text.setText(
"<h3>Scenario modeling in Activity Browser</h3>"
"<p>In <b>Scenario</b> mode, alternative values for flows or parameters can "
"can be defined. Each scenario can thus consider different flow or parameter values defined in two types of scenario files.</p>"
"<h4>Flow scenarios</h4>"
"<p>A <b>flow-scenario</b> file (scenario difference file) identifies flows (left-side part)"
"(e.g. inputs from one to another activity) and contains scenario values for each flow (right-side part). "
"In an empty file, you can start by adding rows via <b>Copy for scenario file</b> on processes or flows, then paste "
"into a template from <b>Template...</b>.</p>"
"<h4>Parameter scenarios</h4>"
"<p>A <b>parameter-scenario</b> file varies Brightway parameters across scenarios."
"The columns are Name and Group (mandatory to identify parameters), then optional default values (as in the database), plus scenario columns. "
"When you load it, AB converts it into flow scenarios for calculation.</p>"
"<h4>Several scenario files</h4>"
"<p>Use <b>Add scenarios...</b> more than once. "
"<b>Combine scenarios</b> builds the product of scenario names across files (parameter and flow scenarios can be mixed); "
"<b>Extend scenarios</b> aligns files on shared scenario names.</p>"
"<h4>Template...</h4>"
"<p>Download an empty flow or parameter starter (.xlsx or .csv). "
"If the project has parameters, the parameter template is filled with "
"Name / Group / default and empty example scenario columns. "
"Lines or columns starting with <code>#</code> are ignored on import "
"(useful for your notes).</p>"
"<h4>Save...</h4>"
"<p>Writes the currently loaded, merged flow-scenario table to a file.</p>"
)
text.setOpenExternalLinks(False)

scroll = QtWidgets.QScrollArea(self)
scroll.setWidgetResizable(True)
scroll.setWidget(text)

buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok, parent=self)
buttons.accepted.connect(self.accept)

layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(scroll)
layout.addWidget(buttons)

Loading
Loading