Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
64def19
Bugfix, Tree tab now woring correctly with scenarios
bsteubing Aug 19, 2026
77b91d2
Bugfix for deleting projects
bsteubing Aug 20, 2026
6870d1f
Adding substitution flows to SDFs
bsteubing Aug 20, 2026
ef95e26
Reload button for scenario files (saves load, choose, ok, ...)
bsteubing Aug 20, 2026
cb6f4f7
Improving the location dropdown in Activity details
bsteubing Aug 20, 2026
198d368
Fixing Linux wayland flags for Qt. Closes #1727
bsteubing Aug 20, 2026
d192b02
scenario file importing bugfixes (# and _ for notes; better error mes…
bsteubing Aug 20, 2026
8953b89
Contribution tables were only showing indexes (numbers) not the impac…
bsteubing Aug 20, 2026
d9e24bf
Adding amount, unit, product / process / location / db to LCA scores …
bsteubing Aug 20, 2026
223e893
Rebuild Brightway’s ParameterizedExchange index upon database write
bsteubing Aug 21, 2026
caebce2
Bringing Pedigree matrix back.
bsteubing Aug 21, 2026
67cb7bb
First version of scenarios within Monte Carlo
bsteubing Aug 28, 2026
e565ee4
Enabling Monte Carlo for scenarios
bsteubing Aug 28, 2026
06f6251
All LCA results tabs now align with the pre-selection of RF, IC, S
bsteubing Aug 28, 2026
f14e275
Adding a minimum width (80 pixels) for the Activity Details tabs
bsteubing Aug 28, 2026
bf7c55f
Fixing broken tests
bsteubing Aug 28, 2026
d432a43
Fixing broken tests 2
bsteubing Aug 29, 2026
d7940b3
Fixing broken tests 3
bsteubing Aug 29, 2026
a8091ed
Merge branch 'major' into bugfixes-mostly
bsteubing Aug 29, 2026
48f9002
Fixing broken tests 4
bsteubing Aug 29, 2026
17cb0b0
Fixing broken tests 5
bsteubing Aug 29, 2026
c52770a
Test speed-ups
bsteubing Aug 29, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,4 @@ jobs:
pip install .[testing]

- name: Test with pytest
run: pytest -q --no-cov -o log_cli=false
run: pytest -q --no-cov -o log_cli=false --durations=25
29 changes: 24 additions & 5 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,23 +80,41 @@ Life Cycle Assessment calculation (inventory + impact). Multi-LCA runs multiple

### Parameter

A named value or formula used to drive exchange amounts or scenarios. Parameter recalculation and Monte Carlo hooks live under `activity_browser/bwutils/parameters/`.
A named value or formula used to drive flow amounts or scenarios. Parameter recalculation and Monte Carlo hooks live under `activity_browser/bwutils/parameters/`.

### Parameterized flow

A flow whose amount is given by a formula (optionally using parameters). The Parameters page lists them under **Parameterized Flows**. Recalculation and Monte Carlo use Brightway’s `ParameterizedExchange` index, which is keyed by activity-parameter group.
_Avoid_: parameterized exchange (when meaning this concept or the UI section)

### Uncertainty

Statistical description of exchange (or parameter or CF) variability (`stats_arrays` types). UI preview helpers live under `activity_browser/bwutils/uncertainty.py` and related dialogs.
Statistical description of flow, parameter, or characterization-factor variability. Monte Carlo samples this description. A lognormal uncertainty on a flow may be derived from an applied pedigree.
_Avoid_: treating stored pedigree scores as the sampled input

### Pedigree

Five 1–5 data-quality scores on a flow (reliability, completeness, temporal correlation, geographical correlation, further technological correlation). Together with basic uncertainty they are a stored recipe for the **spread** of a lognormal uncertainty (not its central value). The pedigree editor is hidden until the user chooses to use pedigree in that edit; using pedigree applies the recipe (lognormal spread from the scores) on confirm. Inspecting scores in the table cell, or cancelling, does not apply. Unchecking use pedigree restores the distribution and parameters from just before they checked; in-session score edits are discarded and the stored recipe is unchanged. Switching to another uncertainty (or removing it) while use pedigree is on keeps that new choice, turns use pedigree off, and leaves the stored recipe. Clearing pedigree also turns it off and restores the sampled fields, and deletes the stored recipe on confirm. Checking use pedigree again before confirm restores the stored recipe (Clear is undone). When present, the scores are shown with the flow’s uncertainty (not as a separate table column). Not used on parameters or characterization factors.
_Avoid_: pedigree matrix (when meaning these scores — that name is the factor table); data quality indicators (when meaning this pedigree); treating an unapplied pedigree as the current uncertainty

### Basic uncertainty

The extra lognormal spread assumed even when all pedigree scores are 1. Part of the pedigree recipe; not sampled on its own. Default is 1 when unset. When the current uncertainty is lognormal and scores exist, it is inferred from that scale and the scores so the recipe matches; if the scale is tighter than the scores alone, inference is not used (default 1). An inferred value is not stored until the user saves the pedigree recipe.
_Avoid_: sample size (the unused sixth ecoinvent pedigree number); treating this as a sixth 1–5 score

### Monte Carlo

Stochastic sampling of uncertain inputs to produce distributions of LCA results. Uncertainties can related to biosphere and technosphere flows, as well as to parameters and characterization factors. See `activity_browser/bwutils/montecarlo.py` and LCA results Monte Carlo UI.
Stochastic sampling of uncertain inputs to produce distributions of LCA results. Uncertainties can relate to biosphere and technosphere flows, as well as to parameters and characterization factors. In **Scenario LCA**, a selected scenario may change flow amounts on the A and B matrices before sampling; those amounts do not relocate database uncertainty. When the same cell is affected by more than one of these, precedence is **scenario amount < uncertainty sampling < parameter sampling** (parameters win on overlap). See `activity_browser/bwutils/montecarlo.py` and LCA results Monte Carlo UI.

### GSA (Global Sensitivity Analysis)

Analysis of how uncertain inputs drive output variance (e.g. SALib-based), based on Monte Carlo snapshots. See `activity_browser/bwutils/sensitivity_analysis.py`.
Analysis of how uncertain inputs drive output variance (e.g. SALib-based), based on Monte Carlo snapshots. In Scenario LCA it uses the last Monte Carlo run’s scenario (not a separate scenario selector). Figure chrome: header shows the last GSA run’s reference flow and impact category; footer shows cutoffs and that Monte Carlo scenario.
_Avoid_: giving GSA its own independent scenario dropdown

### Scenario LCA

An LCA calculation that also considers multiple scenarios for inventory data (based on the superstructure approach). See `activity_browser/bwutils/superstructure/`.
An LCA calculation that also considers multiple scenarios for inventory data (based on the superstructure approach). A chosen scenario replaces selected technosphere and biosphere flow amounts; it does not define new uncertainty. Monte Carlo in scenario mode still samples database (and parameter) uncertainty with precedence scenario amount < uncertainty sampling < parameter sampling. See `activity_browser/bwutils/superstructure/`.
_Avoid_: treating a scenario difference file as an uncertainty distribution

### Scenario name

Expand Down Expand Up @@ -216,3 +234,4 @@ _Avoid_: required amount, supply amount (use flow amount in UI labels)
| “one-shot” / “bw2io native” (LCIA file) | bw2io impact-category file |
| “global app settings file” ad hoc | `app.settings` |
| “activity graph” / Graph tab (the view) / neighbourhood | Graph explorer |
| “parameterized exchanges” (the concept or Parameters page section) | parameterized flows |
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,5 @@ def run_safely(self, copy_from, copy_to, backend):
elif database.backend == "functional_sqlite" and backend == "sqlite":
data = bf.convert_functional_sqlite_to_sqlite(data)

new_database.write(data, searchable=metadata.get("searchable"))
new_database.write(data, searchable=metadata.get("searchable"), signal=True)
return new_database
28 changes: 4 additions & 24 deletions activity_browser/app/actions/exchange/exchange_modify.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
from bw2data.proxies import ExchangeProxyBase

from activity_browser.app.actions.base import ABAction, exception_dialogs
from activity_browser.mod import bw2data as bd
from bw2data.parameters import ActivityParameter
from activity_browser.ui.icons import qicons

from ..parameter.parameter_new_automatic import ParameterNewAutomatic
from .exchange_formula_remove import ExchangeFormulaRemove


Expand Down Expand Up @@ -33,25 +30,8 @@ def run(cls, exchange: ExchangeProxyBase, data: dict):
exchange.save()

if "formula" in data:
cls.parameterize_exchanges(exchange.output.key)
from activity_browser.bwutils.parameters.formula_exchanges import (
index_parameterized_flows_for_process,
)

@staticmethod
def parameterize_exchanges(key: tuple) -> None:
"""Used whenever a formula is set on an exchange in an activity.

If no `ActivityParameter` exists for the key, generate one immediately
"""
act = bd.get_activity(key)
query = (ActivityParameter.database == key[0]) & (
ActivityParameter.code == key[1]
)

if not ActivityParameter.select().where(query).count():
ParameterNewAutomatic.run([key])

group = ActivityParameter.get(query).group

with bd.parameters.db.atomic():
bd.parameters.remove_exchanges_from_group(group, act)
bd.parameters.add_exchanges_to_group(group, act)
ActivityParameter.recalculate_exchanges(group)
index_parameterized_flows_for_process(exchange.output.key)
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from activity_browser import app
from activity_browser.app.actions.base import ABAction, exception_dialogs
from activity_browser.bwutils.commontasks import database_is_locked
from activity_browser.bwutils.uncertainty import uncertainty_initial_from_flow
from activity_browser.ui.icons import qicons
from activity_browser.ui.dialogs import UncertaintyDialog

Expand All @@ -26,9 +27,10 @@ def run(exchanges: List[bd.Edge], uncertainty_dict: dict = None):
if uncertainty_dict is None:
ok, uncertainty_dict = UncertaintyDialog.get_uncertainty_dict(
parent=app.main_window,
initial=exchanges[0].uncertainty,
initial=uncertainty_initial_from_flow(exchanges[0]),
read_only=read_only,
)
enable_pedigree=True,
)

if not ok:
return
Expand All @@ -37,5 +39,9 @@ def run(exchanges: List[bd.Edge], uncertainty_dict: dict = None):

for exchange in exchanges:
for key, value in uncertainty_dict.items():
if key == "pedigree" and value is None:
if "pedigree" in exchange:
del exchange["pedigree"]
continue
exchange[key] = value
exchange.save()
31 changes: 28 additions & 3 deletions activity_browser/app/actions/project/project_delete.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import gc
import shutil
import sqlite3
from pathlib import Path

from qtpy import QtWidgets
Expand Down Expand Up @@ -95,7 +96,13 @@ def delete_project(name: str, delete_dir: bool):
dir_path = _project_directory(name, ds)
assert dir_path.is_dir(), "Can't find project directory"
_close_sqlite_databases_in(dir_path)
shutil.rmtree(dir_path)
try:
shutil.rmtree(dir_path)
except PermissionError:
# Orphan sqlite handles are often released only after GC on Windows.
gc.collect()
_close_sqlite_databases_in(dir_path)
shutil.rmtree(dir_path)

ds.delete_instance()

Expand All @@ -108,14 +115,32 @@ def _project_directory(name: str, ds: ProjectDataset) -> Path:


def _close_sqlite_databases_in(dir_path: Path) -> None:
"""Release peewee SQLite handles so Windows can delete the project directory."""
"""Release SQLite handles so Windows can delete the project directory.

Peewee ``db.close()`` only closes the *current* thread's connection
(``thread_safe=True``). Raw ``sqlite3.connect`` and leftovers after a
worker thread exits are closed via a connection scan.
"""
directory = Path(dir_path)
for _, substitutable_db in config.sqlite3_databases:
try:
if Path(substitutable_db._filepath).is_relative_to(dir_path):
if Path(substitutable_db._filepath).is_relative_to(directory):
if not substitutable_db.db.is_closed():
substitutable_db.db.close()
except Exception:
pass

for obj in gc.get_objects():
if not isinstance(obj, sqlite3.Connection):
continue
try:
for _, _, filename in obj.execute("PRAGMA database_list"):
if filename and Path(filename).is_relative_to(directory):
obj.close()
break
except Exception:
# Other-thread connections raise ProgrammingError; ignore.
pass
gc.collect()


Expand Down
2 changes: 1 addition & 1 deletion activity_browser/app/actions/project/project_migrate25.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ def update_database_activity_types(cls, db_name: str):
ds["type"] = "processwithreferenceproduct"

if write:
database.write(raw)
database.write(raw, signal=True)

@staticmethod
def activity_is_processwithreferenceproduct(ds: dict) -> bool:
Expand Down
10 changes: 7 additions & 3 deletions activity_browser/app/pages/activity_details/activity_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def on_node_deleted(self, node):
Args:
node: The node that was deleted.
"""
if node.id == self.activity.id:
if self.activity is None or node.id == self.activity.id:
self.deleteLater()

def on_database_deleted(self, name):
Expand All @@ -129,7 +129,7 @@ def on_database_deleted(self, name):
Args:
name: The name of the database that was deleted.
"""
if name == self.activity["database"]:
if self.activity is None or name == self.activity["database"]:
self.deleteLater()

def syncLater(self):
Expand All @@ -145,9 +145,13 @@ def sync(self):
self.activity = refresh_node_or_none(self.activity)

if self.activity is None:
# Activity was already deleted
# Activity / database already gone — close rather than sync stale tabs
self.deleteLater()
return

# Keep child tabs aligned with the refreshed proxy
self.parameters_tab.activity = self.activity

# Update the tab name to be the activity name
self.setWindowTitle(self.activity["name"])

Expand Down
45 changes: 41 additions & 4 deletions activity_browser/app/pages/activity_details/activity_header.py
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ class ActivityLocation(QtWidgets.QLineEdit):

_WIDTH_PAD_PX = 14
_MIN_WIDTH_PX = 32
_EDIT_MIN_WIDTH_PX = 180
_EDIT_MAX_WIDTH_PX = 420

def __init__(self, header: ActivityHeader):
"""
Expand All @@ -264,18 +266,53 @@ def __init__(self, header: ActivityHeader):
self.setFixedHeight(fm.height() + 4)
self.setSizePolicy(QtWidgets.QSizePolicy.Policy.Fixed, QtWidgets.QSizePolicy.Policy.Fixed)
self.textChanged.connect(self._adjust_width_to_text)
self._adjust_width_to_text()
self.editingFinished.connect(self.change_location)

locations = set(app.metadata.dataframe.get("location", ["GLO"]))
completer = QtWidgets.QCompleter(locations, self)
locations = {str(loc) for loc in set(app.metadata.dataframe.get("location", ["GLO"])) if loc == loc and loc}
self._edit_min_width = self._width_for_locations(locations)
completer = QtWidgets.QCompleter(sorted(locations), self)
completer.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive)
completer.popup().setMinimumWidth(self._edit_min_width)
self.setCompleter(completer)
self._adjust_width_to_text()

def _width_for_locations(self, locations: set[str]) -> int:
fm = QtGui.QFontMetrics(self.font())
widest = max((fm.horizontalAdvance(loc) for loc in locations), default=0)
return min(
max(widest + self._WIDTH_PAD_PX, self._EDIT_MIN_WIDTH_PX),
self._EDIT_MAX_WIDTH_PX,
)

def _adjust_width_to_text(self) -> None:
fm = QtGui.QFontMetrics(self.font())
t = self.text()
text_w = fm.horizontalAdvance(t) if t else fm.horizontalAdvance(" ")
self.setFixedWidth(max(text_w + self._WIDTH_PAD_PX, self._MIN_WIDTH_PX))
if self.hasFocus():
self.setFixedWidth(max(text_w + self._WIDTH_PAD_PX, self._edit_min_width))
else:
self.setFixedWidth(max(text_w + self._WIDTH_PAD_PX, self._MIN_WIDTH_PX))
if self.hasFocus() and not t.strip():
self._popup_all_locations()

def _popup_all_locations(self) -> None:
"""Show the full location list when the field is blank."""
completer = self.completer()
if completer is None:
return
completer.setCompletionPrefix("")
completer.complete()

def focusInEvent(self, event: QtGui.QFocusEvent) -> None:
super().focusInEvent(event)
self._adjust_width_to_text()
if not self.text().strip():
# Defer so the popup opens after focus is fully established.
QtCore.QTimer.singleShot(0, self._popup_all_locations)

def focusOutEvent(self, event: QtGui.QFocusEvent) -> None:
super().focusOutEvent(event)
self._adjust_width_to_text()

def change_location(self):
"""
Expand Down
18 changes: 9 additions & 9 deletions activity_browser/app/pages/activity_details/exchanges_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
is_node_product_or_waste, is_node_biosphere, parameters_in_scope,
is_node_product, is_node_waste, get_exchange_type,
classify_dragged_nodes)
from activity_browser.bwutils.uncertainty import uncertainty_cell_summary
from activity_browser.bwutils.uncertainty import uncertainty_cell_summary, uncertainty_initial_from_flow
from activity_browser.ui import widgets, icons, delegates, core


Expand Down Expand Up @@ -185,7 +185,10 @@ def build_df(self, exchanges) -> pd.DataFrame:

# Create a DataFrame from the exchanges
exc_df = pd.DataFrame(exchanges, columns=["amount", "input", "formula", "comment", "type"])
exc_df["uncertainty"] = [uncertainty_cell_summary(x.uncertainty) for x in exchanges]
exc_df["uncertainty"] = [
uncertainty_cell_summary(x.uncertainty, pedigree=x.get("pedigree"))
for x in exchanges
]
act_df = app.metadata.get_metadata(exc_df["input"].unique(), cols).rename(columns={"type": "_producer_type"})

# Merge the exchanges DataFrame with the metadata DataFrame
Expand Down Expand Up @@ -662,19 +665,16 @@ def mimeData(self, indices: list[QtCore.QModelIndex]) -> core.ABMimeData:
return data

def uncertainty_editor_initial(self, index: QtCore.QModelIndex) -> dict:
initial = super().uncertainty_editor_initial(index)
if initial:
return initial
row = self.row(index)
if row is None:
return {}
ex = row.get("_exchange")
if ex is None:
return {}
u = getattr(ex, "uncertainty", None) # retrieve the existing uncertainty dict
if isinstance(u, dict):
return dict(u)
return {}
return uncertainty_initial_from_flow(ex)

def uncertainty_editor_enable_pedigree(self, index: QtCore.QModelIndex) -> bool:
return self.column_name(index) == "uncertainty"

def uncertainty_editor_read_only(self, index: QtCore.QModelIndex) -> bool:
if self.column_name(index) != "uncertainty":
Expand Down
19 changes: 16 additions & 3 deletions activity_browser/app/pages/activity_details/parameters_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@

from activity_browser import app
from activity_browser.ui import widgets, icons, delegates, core
from activity_browser.bwutils.commontasks import refresh_node, refresh_parameter, parameters_in_scope, database_is_locked, node_group
from activity_browser.bwutils.commontasks import (
refresh_node,
refresh_node_or_none,
refresh_parameter,
parameters_in_scope,
database_is_locked,
node_group,
)
from activity_browser.bwutils.uncertainty import uncertainty_cell_summary
from activity_browser.bwutils.utils import Parameter

Expand Down Expand Up @@ -68,6 +75,10 @@ def sync(self):
"""
logger.log("SYNC", f"{self.__class__.__name__}: {id(self)}")

self.activity = refresh_node_or_none(self.activity)
if self.activity is None:
return

df = self.build_df()
self.model.set_dataframe(df, group=["_param_type", "_scope"])
self.view.expandAll()
Expand Down Expand Up @@ -105,7 +116,7 @@ def build_df(self) -> pd.DataFrame:
row = self._parameter_to_row(param, db_name, db_name)
translated.append(row)

if not database_is_locked(db_name):
if db_name in bd.databases and not database_is_locked(db_name):
translated.append({
"name": "New parameter...",
"_scope": db_name,
Expand All @@ -123,7 +134,9 @@ def build_df(self) -> pd.DataFrame:
row = self._parameter_to_row(param, f"Group: {group_name}", param.database)
translated.append(row)

if not database_is_locked(self.activity["database"]):
if self.activity["database"] in bd.databases and not database_is_locked(
self.activity["database"]
):
translated.append({
"name": "New parameter...",
"_scope": f"Group: {group_name}",
Expand Down
Loading
Loading