diff --git a/activity_browser/app/pages/activity_details/activity_details.py b/activity_browser/app/pages/activity_details/activity_details.py index de8b25d17..582731989 100644 --- a/activity_browser/app/pages/activity_details/activity_details.py +++ b/activity_browser/app/pages/activity_details/activity_details.py @@ -52,9 +52,9 @@ def __init__(self, activity: tuple | int | bd.Node, parent=None): # Initialize tab widget to hold various detail tabs self.tabs = QtWidgets.QTabWidget(self) - # Initialize and add the Exchanges tab + # Initialize and add the Flows tab self.exchanges_tab = ExchangesTab(activity, self) - self.tabs.addTab(self.exchanges_tab, "Exchanges") + self.tabs.addTab(self.exchanges_tab, "Flows") # Initialize and add the Description tab self.description_tab = DescriptionTab(activity, self) diff --git a/activity_browser/app/pages/activity_details/exchanges_tab.py b/activity_browser/app/pages/activity_details/exchanges_tab.py index f260e9d7e..c65883f4f 100644 --- a/activity_browser/app/pages/activity_details/exchanges_tab.py +++ b/activity_browser/app/pages/activity_details/exchanges_tab.py @@ -548,6 +548,11 @@ def column(self): class ContextMenu(widgets.ABMenu): menuSetup = [ + lambda m: m.add(app.actions.ActivityOpen, [x.input for x in m.exchanges], + enable=bool(m.exchanges), + text="Open process" if len(m.exchanges) == 1 else "Open processes", + ), + lambda m: m.addSeparator(), lambda m: m.add(app.actions.ActivityNewProduct, [m.activity.key], enable=not m.locked and not database_is_legacy(m.activity["database"]) ), @@ -556,11 +561,14 @@ class ContextMenu(widgets.ABMenu): text="Create waste" ), lambda m: m.addSeparator(), - lambda m: m.add(app.actions.ExchangeDelete, m.exchanges, enable=bool(m.exchanges) and not m.locked), - lambda m: m.add(app.actions.ExchangeSDFToClipboard, m.exchanges, enable=bool(m.exchanges)), - lambda m: m.add(app.actions.ActivityOpen, [x.input for x in m.exchanges], + lambda m: m.add(app.actions.ExchangeDelete, m.exchanges, + enable=bool(m.exchanges) and not m.locked, + text="Delete flow" if len(m.exchanges) == 1 else "Delete flows", + ), + lambda m: m.addSeparator(), + lambda m: m.add(app.actions.ExchangeSDFToClipboard, m.exchanges, enable=bool(m.exchanges), - text="Open processs" if len(m.exchanges) == 1 else "Open processes", + text="Copy for scenario file", ), ] diff --git a/activity_browser/app/panes/database_products.py b/activity_browser/app/panes/database_products.py index 1a0e3786d..9b02ebbb1 100644 --- a/activity_browser/app/panes/database_products.py +++ b/activity_browser/app/panes/database_products.py @@ -277,10 +277,6 @@ class ContextMenu(ui.widgets.ABMenu): lambda m, p: m.add(app.actions.ActivityNewProcess, p.db_name, enable=not database_is_locked(p.db_name), ), - lambda m, p: m.add(app.actions.NewElementaryFlow, p.db_name, - enable=p.db_name in get_writable_databases(), - text="New elementary flow", - ), lambda m, p: m.add(app.actions.ActivityDuplicate, p.selected_activities, text="Duplicate process" if len(p.selected_activities) == 1 else "Duplicate processes", enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), @@ -289,15 +285,6 @@ class ContextMenu(ui.widgets.ABMenu): text="Duplicate process to database" if len(p.selected_activities) == 1 else "Duplicate processes to database", enable=len(p.selected_activities) > 0, ), - lambda m: m.addSeparator(), - lambda m, p: m.add(app.actions.EditElementaryFlow, p.selected_elementary_flows, - text="Edit elementary flow", - enable=len(p.selected_elementary_flows) == 1 and p.db_name in get_writable_databases(), - ), - lambda m, p: m.add(app.actions.DeleteElementaryFlow, p.selected_elementary_flows, - text="Delete elementary flow" if len(p.selected_elementary_flows) == 1 else "Delete elementary flows", - enable=len(p.selected_elementary_flows) > 0 and p.db_name in get_writable_databases(), - ), lambda m, p: m.add(app.actions.ActivityDelete, p.selected_activities, text="Delete process" if len(p.selected_activities) == 1 else "Delete processes", enable=len(p.selected_activities) > 0 and not database_is_locked(p.db_name), @@ -309,13 +296,28 @@ class ContextMenu(ui.widgets.ABMenu): database_is_legacy(p.db_name), ), lambda m: m.addSeparator(), + lambda m, p: m.add(app.actions.NewElementaryFlow, p.db_name, + enable=p.db_name in get_writable_databases(), + text="New elementary flow", + ), + lambda m, p: m.add(app.actions.EditElementaryFlow, p.selected_elementary_flows, + text="Edit elementary flow", + enable=len(p.selected_elementary_flows) == 1 and p.db_name in get_writable_databases(), + ), + lambda m, p: m.add(app.actions.DeleteElementaryFlow, p.selected_elementary_flows, + text="Delete elementary flow" if len(p.selected_elementary_flows) == 1 else "Delete elementary flows", + enable=len(p.selected_elementary_flows) > 0 and p.db_name in get_writable_databases(), + ), + lambda m: m.addSeparator(), lambda m, p: m.add(app.actions.CSNew, functional_units=[{prod: m.get_functional_unit_amount(prod)} for prod in p.selected_products], enable=len(p.selected_products) > 0, - text="Create setup", + text="New setup", ), + lambda m: m.addSeparator(), lambda m, p: m.add(app.actions.ActivitySDFToClipboard, p.selected_products, enable=len(p.selected_products) > 0, + text="Copy for scenario file", ), ] diff --git a/activity_browser/bwutils/exporters.py b/activity_browser/bwutils/exporters.py index 25ea01d31..88ebcb47a 100644 --- a/activity_browser/bwutils/exporters.py +++ b/activity_browser/bwutils/exporters.py @@ -1,5 +1,6 @@ -import numbers import json +import math +import numbers from datetime import datetime as dt from pathlib import Path from typing import Union @@ -16,7 +17,6 @@ # Copied most of this code wholesale from bw2io package. # TODO: reminder to make a pull-request for these things in bw2io repo. -# - Add the 'nan_inf_to_errors' option when opening the xlsxwriter.Workbook. # - Add handler for pedigree data to exporter # - Add 'database' field as required CSVFormatter export field. # - Add code to ensure no second 'activity' field is exported, as this @@ -105,7 +105,9 @@ def write_lci_excel(db_name: str, path: str, objs=None, sections=None) -> Path: else: out_file = path - workbook = xlsxwriter.Workbook(out_file, {"nan_inf_to_errors": True}) + # Leave non-finite floats blank. Uncertainty dicts often store np.nan for + # unused stats_arrays fields; nan_inf_to_errors would turn those into #NUM!. + workbook = xlsxwriter.Workbook(out_file) bold = workbook.add_format({"bold": True}) bold.set_font_size(12) highlighted = { @@ -127,7 +129,9 @@ def write_lci_excel(db_name: str, path: str, objs=None, sections=None) -> Path: if value is None: continue elif isinstance(value, numbers.Number): - sheet.write_number(row_index, col_index, value, frmt(value)) + # Keep bools as 0/1 (previous write_number behaviour); skip NaN/Inf. + if isinstance(value, bool) or math.isfinite(float(value)): + sheet.write_number(row_index, col_index, value, frmt(value)) else: sheet.write_string(row_index, col_index, frmt_str(value), frmt(value)) diff --git a/activity_browser/bwutils/importers.py b/activity_browser/bwutils/importers.py index 75c0822af..aa5bce02a 100644 --- a/activity_browser/bwutils/importers.py +++ b/activity_browser/bwutils/importers.py @@ -13,7 +13,6 @@ csv_restore_booleans, csv_restore_tuples, drop_falsey_uncertainty_fields_but_keep_zeros, link_iterable_by_fields, - link_technosphere_by_activity_hash, normalize_biosphere_categories, normalize_biosphere_names, normalize_units, set_code_by_activity_hash, @@ -24,7 +23,8 @@ from .errors import LinkingFailed from .strategies import (alter_database_name, csv_rewrite_product_key, hash_parameter_group, link_exchanges_without_db, - link_functional_processors, relink_exchanges_bw2package, + link_functional_processors, link_technosphere_same_database, + relink_exchanges_bw2package, relink_exchanges_with_db, rename_db_bw2package, parse_JSON_fields, metadatastore_link, alter_exchange_database_name) @@ -57,7 +57,7 @@ def _excel_link_strategies(relink: dict | None = None): other=bd.Database(bd.config.biosphere), kind="biosphere", ), - link_technosphere_by_activity_hash, + link_technosphere_same_database, ] if relink is not None: strategies.extend([ diff --git a/activity_browser/bwutils/strategies.py b/activity_browser/bwutils/strategies.py index a2f40a5cb..e40d496ec 100644 --- a/activity_browser/bwutils/strategies.py +++ b/activity_browser/bwutils/strategies.py @@ -12,6 +12,7 @@ from bw2io.errors import StrategyError from bw2io.strategies.generic import (format_nonunique_key_error, link_iterable_by_fields) +from bw2io.strategies import link_technosphere_by_activity_hash from bw2io.utils import DEFAULT_FIELDS, activity_hash import bw2data as bd @@ -63,6 +64,36 @@ def _relink_exchange_input(exc: dict, relink: dict) -> None: exc["input"] = new_key +_SKIP_INTERNAL_LINK = ("__ab_skip_internal_link__", "") + + +def link_technosphere_same_database(data: list) -> list: + """Link technosphere exchanges internally, respecting exchange ``database``. + + ``bw2io``'s ``link_technosphere_by_activity_hash`` matches on name/location/unit + fields only. That wrongly self-links exchanges that already name another + database (e.g. a local market activity that also imports the same-named market + from an external DB). Leave those unlinked for later external linking. + """ + protected = [] + for ds in data: + db = ds.get("database") + for exc in ds.get("exchanges", []): + if exc.get("input") or exc.get("type") not in TECHNOSPHERE_TYPES: + continue + exc_db = exc.get("database") + if exc_db and db and exc_db != db: + exc["input"] = _SKIP_INTERNAL_LINK + protected.append(exc) + + link_technosphere_by_activity_hash(data) + + for exc in protected: + if exc.get("input") == _SKIP_INTERNAL_LINK: + del exc["input"] + return data + + def link_functional_processors(data: list) -> list: """Ensure product ``processor`` fields reference process nodes in the same import. diff --git a/tests/test_excel_link_technosphere.py b/tests/test_excel_link_technosphere.py new file mode 100644 index 000000000..75373e820 --- /dev/null +++ b/tests/test_excel_link_technosphere.py @@ -0,0 +1,63 @@ +"""Excel import: internal technosphere linking must respect exchange database.""" +from copy import deepcopy + +from activity_browser.bwutils.strategies import link_technosphere_same_database + + +def _ev_case_like_dataset(): + """Local market plus a same-named technosphere input from another database.""" + return [ + { + "database": "EV case", + "code": "local_market", + "name": "market for electricity, low voltage", + "location": "NL", + "unit": "kilowatt hour", + "reference product": "electricity, low voltage", + "exchanges": [ + { + "type": "production", + "amount": 1, + "name": "market for electricity, low voltage", + "database": "EV case", + "location": "NL", + "unit": "kilowatt hour", + "reference product": "electricity, low voltage", + }, + { + "type": "technosphere", + "amount": 1, + "name": "market for electricity, low voltage", + "database": "ei3.11-REMIND", + "location": "NL", + "unit": "kilowatt hour", + "reference product": "electricity, low voltage", + }, + { + "type": "technosphere", + "amount": 0.25, + "name": "market for electricity, low voltage", + "database": "EV case", + "location": "NL", + "unit": "kilowatt hour", + "reference product": "electricity, low voltage", + }, + ], + } + ] + + +def test_foreign_database_technosphere_not_self_linked(): + data = link_technosphere_same_database(deepcopy(_ev_case_like_dataset())) + exchanges = data[0]["exchanges"] + production = next(e for e in exchanges if e["type"] == "production") + foreign = next(e for e in exchanges if e.get("database") == "ei3.11-REMIND") + local = next( + e + for e in exchanges + if e["type"] == "technosphere" and e.get("database") == "EV case" + ) + + assert production.get("input") == ("EV case", "local_market") + assert local.get("input") == ("EV case", "local_market") + assert "input" not in foreign diff --git a/tests/test_excel_uncertainty_export.py b/tests/test_excel_uncertainty_export.py new file mode 100644 index 000000000..0c8903641 --- /dev/null +++ b/tests/test_excel_uncertainty_export.py @@ -0,0 +1,191 @@ +"""Excel export must not write #NUM! for unused uncertainty NaN fields.""" +from __future__ import annotations + +import tempfile +from pathlib import Path + +import bw2data as bd +import openpyxl +import pytest +import stats_arrays as sa +from bw2data.parameters import ActivityParameter, Group, parameters +from bw2data.tests import bw2test +from bw2io import create_core_migrations + +from activity_browser.bwutils.exporters import write_lci_excel +from activity_browser.bwutils.importers import ABExcelImporter +from activity_browser.bwutils.uncertainty import EMPTY_UNCERTAINTY + + +def _uncertainty(**fields) -> dict: + return {**EMPTY_UNCERTAINTY, **fields} + + +def _excel_error_cells(path: Path) -> list[tuple[int, int, object]]: + """Return (row, col, value) for cells stored as Excel errors / #NUM formulas.""" + wb = openpyxl.load_workbook(path, read_only=True) + try: + errors = [] + for r_i, row in enumerate(wb.active.iter_rows(), start=1): + for cell in row: + val = cell.value + if cell.data_type == "e": + errors.append((r_i, cell.column, val)) + elif isinstance(val, str) and "NUM" in val.upper(): + errors.append((r_i, cell.column, val)) + return errors + finally: + wb.close() + + +def _setup_project_with_uncertainty() -> str: + create_core_migrations() + bio_name = bd.config.biosphere + bio = bd.Database(bio_name) + bio.register() + bio.write({ + (bio_name, "co2"): { + "name": "Carbon dioxide", + "code": "co2", + "database": bio_name, + "unit": "kilogram", + "type": "emission", + "categories": ("air",), + "exchanges": [], + }, + }) + + db_name = "uncert_db" + lognormal = _uncertainty( + **{"uncertainty type": sa.LognormalUncertainty.id, "loc": 0.0, "scale": 0.25}, + ) + triangular = _uncertainty( + **{ + "uncertainty type": sa.TriangularUncertainty.id, + "minimum": 0.5, + "loc": 1.0, + "maximum": 2.0, + }, + ) + normal_bio = _uncertainty( + **{"uncertainty type": sa.NormalUncertainty.id, "loc": 1.5, "scale": 0.1}, + ) + uniform = _uncertainty( + **{ + "uncertainty type": sa.UniformUncertainty.id, + "minimum": 0.1, + "maximum": 0.9, + }, + ) + + db = bd.Database(db_name) + db.register() + db.write({ + (db_name, "a"): { + "name": "process A", + "code": "a", + "database": db_name, + "location": "GLO", + "unit": "kg", + "type": "process", + "exchanges": [ + {"input": (db_name, "a"), "type": "production", "amount": 1.0}, + { + "input": (db_name, "b"), + "type": "technosphere", + "amount": 2.0, + **lognormal, + }, + { + "input": (bio_name, "co2"), + "type": "biosphere", + "amount": 3.0, + **normal_bio, + }, + ], + }, + (db_name, "b"): { + "name": "process B", + "code": "b", + "database": db_name, + "location": "GLO", + "unit": "kg", + "type": "process", + "exchanges": [ + { + "input": (db_name, "b"), + "type": "production", + "amount": 1.0, + **triangular, + }, + ], + }, + }) + + Group.create(name="g_a", order=[]) + parameters.new_activity_parameters( + [{ + "name": "share", + "amount": 0.5, + "database": db_name, + "code": "a", + **uniform, + }], + "g_a", + ) + return db_name + + +@pytest.mark.skip(reason="Slow (~13s) bw2test Excel uncertainty roundtrip; run manually when needed") +@bw2test +def test_excel_uncertainty_export_has_no_num_errors_and_roundtrips(): + db_name = _setup_project_with_uncertainty() + + with tempfile.TemporaryDirectory() as tmp: + path = write_lci_excel(db_name, str(Path(tmp) / "uncert.xlsx")) + assert _excel_error_cells(path) == [] + + target = "uncert_imported" + importer = ABExcelImporter(str(path)) + importer.apply_basic_strategies() + importer.apply_db_name(target) + importer.apply_linking({}) + assert not list(importer.unlinked) + importer.write_database(delete_existing=True, activate_parameters=True) + + by_type = {} + for act in bd.Database(target): + if act["name"] != "process A": + continue + for exc in act.exchanges(): + by_type[exc["type"]] = exc + + tech = by_type["technosphere"] + assert tech["uncertainty type"] == sa.LognormalUncertainty.id + assert tech["loc"] == 0.0 + assert tech["scale"] == 0.25 + + bio = by_type["biosphere"] + assert bio["uncertainty type"] == sa.NormalUncertainty.id + assert bio["loc"] == 1.5 + assert bio["scale"] == 0.1 + + prod = next( + exc + for act in bd.Database(target) + if act["name"] == "process B" + for exc in act.exchanges() + if exc["type"] == "production" + ) + assert prod["uncertainty type"] == sa.TriangularUncertainty.id + assert prod["loc"] == 1.0 + assert prod["minimum"] == 0.5 + assert prod["maximum"] == 2.0 + + param = ActivityParameter.get( + (ActivityParameter.database == target) & (ActivityParameter.name == "share") + ) + pdata = dict(param.dict) + assert pdata.get("uncertainty type") == sa.UniformUncertainty.id + assert pdata.get("minimum") == 0.1 + assert pdata.get("maximum") == 0.9