diff --git a/activity_browser/app/pages/activity_details/activity_header.py b/activity_browser/app/pages/activity_details/activity_header.py index ad935474a..adf06f77b 100644 --- a/activity_browser/app/pages/activity_details/activity_header.py +++ b/activity_browser/app/pages/activity_details/activity_header.py @@ -308,8 +308,13 @@ def __init__(self, parent: ActivityHeader, disabled: bool = False): database_widget = QtWidgets.QLabel(parent.activity.get("database", "unspecified"), self) database_widget.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) + db_icon = ( + "database_functional_sqlite" + if parent._database_backend == "functional_sqlite" + else "database" + ) layout.addWidget(location_widget, 0) - layout.addWidget(parent._icon_label("database", "Database")) + layout.addWidget(parent._icon_label(db_icon, "Database")) layout.addWidget(database_widget, 1) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index 1db5f4ff6..e92d6e7c9 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -13,7 +13,8 @@ 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, get_exchange_type) + is_node_product, is_node_waste, get_exchange_type, + classify_dragged_nodes) from activity_browser.bwutils.uncertainty import uncertainty_cell_summary from activity_browser.ui import widgets, icons, delegates, core @@ -356,26 +357,7 @@ def action_from_mime(self, mime: core.ABMimeData) -> Literal["product", "waste", """ keys = mime.retrievePickleData("application/bw-nodekeylist") - 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") - - if len(actions) != 1: - return "generic" - return actions.pop() # type: ignore[return-value] + return classify_dragged_nodes(keys) # type: ignore[return-value] class RelinkDelegate(delegates.StringDelegate): diff --git a/activity_browser/app/pages/calculation_setup/scenario_section.py b/activity_browser/app/pages/calculation_setup/scenario_section.py index d6623f683..75f6ec202 100644 --- a/activity_browser/app/pages/calculation_setup/scenario_section.py +++ b/activity_browser/app/pages/calculation_setup/scenario_section.py @@ -800,6 +800,8 @@ def sync_inclusion_flags(self, flags: list[bool]) -> None: def scenario_db_check(self, df: pd.DataFrame) -> pd.DataFrame: dbs = set(df.loc[:, "from database"]).union(set(df.loc[:, "to database"])) + # Ignore missing / non-string cells (e.g. NaN) — they are not DB names. + dbs = {db for db in dbs if isinstance(db, str) and db.strip()} unlinkable = dbs.difference(bd.databases) db_lst = list(bd.databases) relink = [] @@ -1149,11 +1151,12 @@ def __init__(self, parent=None): "
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 # are ignored on import "
- "(useful for your notes).
Writes the currently loaded, merged flow-scenario table to a file.
" + "Lines starting with # and columns starting with _ are ignored on import " + "(useful for your notes).
" ) text.setOpenExternalLinks(False) diff --git a/activity_browser/bwutils/commontasks.py b/activity_browser/bwutils/commontasks.py index 48d28815a..4ff0404ad 100644 --- a/activity_browser/bwutils/commontasks.py +++ b/activity_browser/bwutils/commontasks.py @@ -347,6 +347,38 @@ def get_exchange_type(activity_key: tuple, output: bool = False) -> str | None: return None +def classify_dragged_nodes(keys: list) -> str: + """ + Overlay / drop action for a set of dragged node keys. + + ``ProductModel.mimeData`` includes both product/waste keys and their processor + process keys; process nodes are ignored so a product+processor drag stays + ``product`` (same idea as discarding process types from metadata). + """ + 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") + elif is_node_process(key): + continue + else: + actions.add("generic") + + if len(actions) != 1: + return "generic" + return actions.pop() + + def is_node_process(node: tuple | int | bd.Node) -> bool: node = refresh_node(node) raw_type = node._document.type diff --git a/activity_browser/bwutils/superstructure/excel.py b/activity_browser/bwutils/superstructure/excel.py index ab0ae1b61..b9e182668 100644 --- a/activity_browser/bwutils/superstructure/excel.py +++ b/activity_browser/bwutils/superstructure/excel.py @@ -11,8 +11,6 @@ from .dataframe import ensure_string_scenario_names - - def convert_tuple_str(x): try: return literal_eval(x) @@ -39,7 +37,8 @@ def get_header_index(document_path: Union[str, Path], import_sheet: int): sheet = wb.worksheets[import_sheet] for i in range(10): value = sheet.cell(i + 1, 1).value - if isinstance(value, str): + # Skip SDF comment rows (first cell starts with '#'). + if isinstance(value, str) and not value.startswith("#"): wb.close() return i except IndexError as e: @@ -54,8 +53,8 @@ def get_header_index(document_path: Union[str, Path], import_sheet: int): def valid_cols(name: str) -> bool: - """Callable which evaluates if a specific column should be used.""" - return False if str(name).startswith("#") else True + """True for data columns; names starting with '_' are SDF comment columns (not imported).""" + return not str(name).startswith("_") def import_from_excel( @@ -67,12 +66,8 @@ def import_from_excel( The default index chosen represents the second sheet (first after the 'information' sheet). - A '#' character at the start of a row causes that row to be excluded from - the import. A '#' character at the start of a column name causes that - column to be excluded from the import. - - 'usecols' is used to exclude specific columns from the excel document. - 'comment' is used to exclude specific rows from the excel document. + Comment rows: a '#' at the start of a row (pandas ``comment='#'``). + Comment columns: a column name starting with '_' (``usecols=valid_cols``). """ data = pd.DataFrame({}) try: diff --git a/activity_browser/bwutils/superstructure/file_imports.py b/activity_browser/bwutils/superstructure/file_imports.py index 4d81142a8..2c366a998 100644 --- a/activity_browser/bwutils/superstructure/file_imports.py +++ b/activity_browser/bwutils/superstructure/file_imports.py @@ -8,6 +8,8 @@ from ..errors import * from .dataframe import ensure_string_scenario_names +from .excel import valid_cols + @@ -228,6 +230,7 @@ def read_file(path: Optional[Union[str, Path]], **kwargs): sep=separator, index_col=False, comment="#", + usecols=valid_cols, converters={"from key": ast.literal_eval, "to key": ast.literal_eval}, ) # Scenario headers typed as numbers (e.g. 2025) must be strings. diff --git a/activity_browser/static/icons/activity details/database_functional_sqlite.png b/activity_browser/static/icons/activity details/database_functional_sqlite.png new file mode 100644 index 000000000..b7228ea26 Binary files /dev/null and b/activity_browser/static/icons/activity details/database_functional_sqlite.png differ diff --git a/activity_browser/templates/README.md b/activity_browser/templates/README.md index a21902d06..d07e987fc 100644 --- a/activity_browser/templates/README.md +++ b/activity_browser/templates/README.md @@ -25,7 +25,10 @@ flow = templates / "scenarios" / "flow-scenarios.xlsx" Excel workbooks: **data sheet first**, then **`README`**. CSV files: header row, blank rows, then notes on lines starting with `#` (ignored on import). -Rows or columns whose first cell / header starts with `#` are ignored by scenario import (Excel and CSV). +Scenario import comments (Excel and CSV): + +- **Rows:** start with `#` (ignored via pandas `comment="#"`). +- **Columns:** name starts with `_` (e.g. `_notes`; dropped via `usecols`). **Get template → flow-scenarios** always copies the empty starter file (does not generate from project parameters). diff --git a/activity_browser/templates/scenarios/flow-scenarios.csv b/activity_browser/templates/scenarios/flow-scenarios.csv index 78d1d19f0..d1fe38515 100644 --- a/activity_browser/templates/scenarios/flow-scenarios.csv +++ b/activity_browser/templates/scenarios/flow-scenarios.csv @@ -3,8 +3,8 @@ from activity name;from reference product;from location;from categories;from dat ;;;;;;;;;;;;;;; # Flow scenario template (CSV);;;;;;;;;;;;;;; ;;;;;;;;;;;;;;; -# Rows or columns whose first cell / header starts with # are ignored when Activity Browser loads the file. Use # to leave notes in the file.;;;;;;;;;;;;;;; "# How to fill: in Activity Browser, select flows/products, right-click ""Copy for scenario file"", paste under the header row, then enter scenario amounts.";;;;;;;;;;;;;;; "# Empty scenario cells mean ""use the default inventory amount"".";;;;;;;;;;;;;;; # Rename S1/S2/S3 example headers to your scenario names as needed.;;;;;;;;;;;;;;; +# Comment rows: start the row with # (ignored on import). Comment columns: name them with a leading _ (e.g. _notes).;;;;;;;;;;;;;;; # Load via calculation setup Scenario mode, Add scenarios...;;;;;;;;;;;;;;; diff --git a/activity_browser/templates/scenarios/flow-scenarios.xlsx b/activity_browser/templates/scenarios/flow-scenarios.xlsx index ccf709a80..1135a6238 100644 Binary files a/activity_browser/templates/scenarios/flow-scenarios.xlsx and b/activity_browser/templates/scenarios/flow-scenarios.xlsx differ diff --git a/activity_browser/templates/scenarios/parameter-scenarios.csv b/activity_browser/templates/scenarios/parameter-scenarios.csv index 39cfaf8c1..576f2874a 100644 --- a/activity_browser/templates/scenarios/parameter-scenarios.csv +++ b/activity_browser/templates/scenarios/parameter-scenarios.csv @@ -3,7 +3,7 @@ Name;Group;default;S1 example;S2 example;S3 example ;;;;; # Parameter scenario template (CSV);;;;; ;;;;; -# Rows or columns whose first cell / header starts with # are ignored when Activity Browser loads the file. Use # to leave notes in the file.;;;;; "# Columns: Name and Group must match parameters in your project; default is optional; add scenario columns with numeric values.";;;;; "# Prefer ""Get template"" from the calculation setup Scenarios tools to start from your project parameters when available.";;;;; +# Comment rows: start the row with # (ignored on import). Comment columns: name them with a leading _ (e.g. _notes).;;;;; # Load via calculation setup Scenario mode, Add scenarios... (AB converts parameter scenarios to flow scenarios for LCA).;;;;; diff --git a/activity_browser/templates/scenarios/parameter-scenarios.xlsx b/activity_browser/templates/scenarios/parameter-scenarios.xlsx index fe4a405c3..fccb79985 100644 Binary files a/activity_browser/templates/scenarios/parameter-scenarios.xlsx and b/activity_browser/templates/scenarios/parameter-scenarios.xlsx differ diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index a4565b0fe..b27da1583 100644 --- a/activity_browser/ui/icons.py +++ b/activity_browser/ui/icons.py @@ -79,6 +79,7 @@ def empty_icon(size: QSize = QSize(32, 32)) -> QIcon: # activity details location = create_path("activity details", "location.png"), database = create_path("activity details", "database.png"), + database_functional_sqlite = create_path("activity details", "database_functional_sqlite.png"), allocation = create_path("activity details", "allocation.png"), properties = create_path("activity details", "properties.png"), diff --git a/docs/advanced-topics/scenario-calculations.md b/docs/advanced-topics/scenario-calculations.md index 175d276bf..a94bf0211 100644 --- a/docs/advanced-topics/scenario-calculations.md +++ b/docs/advanced-topics/scenario-calculations.md @@ -28,6 +28,15 @@ Flow scenarios allow you to directly change the values of flows in the technosph When you import a flow scenario into a calculation setup, the Activity Browser will directly substitute the values in the technosphere matrix with the ones defined in the scenario file during calculation. +### Comments in scenario files + +Flow scenario (SDF) files may include comments that are ignored on import: + +- **Comment rows:** start the row with `#` (first cell, or a full CSV line). Handled by pandas `comment="#"`. +- **Comment columns:** give the column a name that starts with `_` (for example `_notes`). These are dropped via `usecols`. + +Do not start comment **column** names with `#` — that conflicts with pandas row comments and can corrupt the header. + ## Combining or extending scenarios You can add multiple scenarios to a calculation setup. This allows you to easily compare different versions of your model and see how changes in one scenario affect the results of another. diff --git a/tests/test_product_waste_links.py b/tests/test_product_waste_links.py index e7b7b39c0..d8a037ff8 100644 --- a/tests/test_product_waste_links.py +++ b/tests/test_product_waste_links.py @@ -14,6 +14,7 @@ from bw2data.tests import bw2test from activity_browser.bwutils.commontasks import ( + classify_dragged_nodes, get_exchange_type, is_node_product, is_node_waste, @@ -190,6 +191,10 @@ def test_functional_product_waste_links(): assert is_node_waste((DB, "w")) and not is_node_product((DB, "w")) _assert_drop_cases(FUNCTIONAL_DROP_CASES) + # ProductModel.mimeData attaches product/waste key + processor process + assert classify_dragged_nodes([(DB, "p"), (DB, "P")]) == "product" + assert classify_dragged_nodes([(DB, "w"), (DB, "W")]) == "waste" + for host, dragged, on_output in LINK_SPECS: _add_drop_link((DB, host), (DB, dragged.lower()), on_output) bf.FunctionalSQLiteDatabase(DB).process() diff --git a/tests/test_sdf_comment_columns.py b/tests/test_sdf_comment_columns.py new file mode 100644 index 000000000..65aa37077 --- /dev/null +++ b/tests/test_sdf_comment_columns.py @@ -0,0 +1,40 @@ +"""SDF comments: '#' rows (pandas comment=) and '_' columns (usecols). + +Keep this file free of Excel/openpyxl I/O so it stays cheap in CI. +Excel uses the same pandas knobs; column rule is covered by ``valid_cols``. +""" +from activity_browser.bwutils.superstructure.excel import valid_cols +from activity_browser.bwutils.superstructure.file_imports import ABCSVImporter +from activity_browser.bwutils.superstructure.utils import SUPERSTRUCTURE + + +def test_valid_cols_drops_underscore_prefix(): + assert valid_cols("_notes") is False + assert valid_cols("2025") is True + assert valid_cols("from database") is True + + +def test_csv_hash_rows_and_underscore_columns(tmp_path): + cols = list(SUPERSTRUCTURE) + ["_notes", "2025"] + header = ";".join(cols) + data = ( + "A;p;GLO;;db1;('db1', 'a');B;q;GLO;;db2;('db2', 'b');technosphere;x;1.0" + ) + text = "\n".join( + [ + "# leading file comment", + header, + data, + "# skipped data row", + ] + ) + path = tmp_path / "sdf.csv" + path.write_text(text, encoding="utf-8") + + df = ABCSVImporter.read_file(path, separator=";") + + assert list(df.columns) == cols[:-2] + ["2025"] # _notes dropped + assert "_notes" not in df.columns + assert len(df) == 1 + assert df.loc[0, "from database"] == "db1" + assert float(df.loc[0, "2025"]) == 1.0