Skip to content

Commit 95190a3

Browse files
committed
Merge remote-tracking branch 'origin/main' into major
# Conflicts: # activity_browser/actions/exchange/exchange_formula_remove.py # activity_browser/actions/exchange/exchange_modify.py # activity_browser/bwutils/montecarlo.py # activity_browser/layouts/panels/right.py # activity_browser/logger.py # activity_browser/ui/tables/models/activity.py # activity_browser/ui/web/navigator.py
2 parents 7a6f9bc + 4f7b073 commit 95190a3

11 files changed

Lines changed: 302 additions & 171 deletions

File tree

activity_browser/actions/exchange/exchange_formula_remove.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from typing import Any, List
22

3+
from bw2data.parameters import ParameterizedExchange
4+
35
from activity_browser.actions.base import ABAction, exception_dialogs
46
from activity_browser.ui.icons import qicons
57

@@ -16,9 +18,10 @@ class ExchangeFormulaRemove(ABAction):
1618
@exception_dialogs
1719
def run(exchanges: List[Any]):
1820
for exchange in exchanges:
19-
try:
20-
del exchange["formula"]
21-
exchange.save()
22-
except KeyError:
23-
# formula not in the exchange
24-
continue
21+
if "formula" not in exchange:
22+
return
23+
24+
del exchange["formula"]
25+
exchange.save()
26+
27+
ParameterizedExchange.delete().where(ParameterizedExchange.exchange == exchange._document.id).execute()

activity_browser/actions/exchange/exchange_modify.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from activity_browser.ui.icons import qicons
1111

1212
from ..parameter.parameter_new_automatic import ParameterNewAutomatic
13+
from .exchange_formula_remove import ExchangeFormulaRemove
1314

1415
log = getLogger(__name__)
1516

@@ -25,8 +26,14 @@ class ExchangeModify(ABAction):
2526
@classmethod
2627
@exception_dialogs
2728
def run(cls, exchange: ExchangeProxyBase, data: dict):
29+
# remove the formula if it is an empty string
30+
if "formula" in exchange and data.get("formula") == "":
31+
del data["formula"]
32+
ExchangeFormulaRemove.run([exchange])
33+
2834
for key, value in data.items():
2935
exchange[key] = value
36+
3037
exchange.save()
3138

3239
if "formula" in data:

activity_browser/docs/wiki/LCA-Results.md

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ Activity Browser has three contribution analysis approaches available to assess
1818
`Elementary Flow (EF) Contributions`, `Process contributions` and `First Tier (FT) Contributions`.
1919

2020
Before we discuss the different approaches, we introduce a small example for the production of _'steel'_:
21+
These approaches are extensively discussed independent of Activity Browser by
22+
[van der Meide et al. (2025)](https://doi.org/10.31219/osf.io/sfgj6_v1)
23+
if you want to learn more.
2124

2225
![steel production example](./assets/steel_production_example.svg)
2326

@@ -33,7 +36,8 @@ The amounts we use are:
3336
<b>Note:</b> These numbers are used for ease of understanding, not for realism.
3437
</sup>
3538

36-
To produce 1 kg of steel, we get a climate change impact of 1.6 kg CO2 eq with the _'IPCC 2021'_ impact category.
39+
To produce 1 kg of steel, we get a climate change impact of 1.6 kg CO<sub>2</sub> eq. with the
40+
_'IPCC 2021 GWP 100'_ impact category.
3741
In the way Brightway (and thus Activity Browser) calculate results, a _contribution matrix_ is calculated with
3842
all impacts _from_ all EFs and all activities.
3943
For the system and functional unit above, this would be:
@@ -46,15 +50,15 @@ For the system and functional unit above, this would be:
4650
The _contribution matrix_ show the dis-aggregated results for each individual biosphere flow for each activity.
4751

4852
#### Elementary Flow (EF) contributions
49-
If we take sum the _rows_ to one row, we get the EF contributions
53+
If we take the sum the _rows_ to one column, we get the EF contributions
5054
(the contribution of all CO<sub>2</sub> and CH<sub>4</sub> impacts together).
5155

5256
In the case above, the EF contributions are:
5357
- CO<sub>2</sub>: 1.5404... (96.3%)
5458
- CH<sub>4</sub>: 0.0596... (3.7%)
5559

5660
#### Process contributions
57-
If we take the sum of the _columns_ to one column, we get the process contributions
61+
If we take the sum of the _columns_ to one row, we get the process contributions
5862
(the contribution of all coal, electricity and steel production impacts together).
5963

6064
In the case above, the process contributions are:
@@ -115,9 +119,19 @@ in the next sections.
115119
![contributions cutoff](./assets/contribution_manipulation.png)
116120

117121
#### Cut-off
118-
You can manually change the `Cut-off type` of the results in two ways, `Relative` or `Top #`.
119-
- The `Relative` mode shows contributions _from_ entities of _x_% or higher.
122+
You can manually change the `Cut-off type` of the results in three ways:
123+
- The `Minimum %` mode shows contributions _from_ entities of at least _x_% or higher.
124+
- For example: If the cut-off is set to 5% for process contribtions, then all contributions of at least 5% are shown.
125+
- The `Cumulative %` mode shows contributions that cumulatively contribute at least _x_%.
126+
- For example: If the cut-off is set to 80% for process contributions, then the first _n_ processes (sorted highest
127+
to lowest) that count up to 80% are shown.
120128
- The `Top #` mode shows contributions from the _x_ entities that contribute the most (as absolute).
129+
- For example: If the cut-off is set to 5, then the first 5 processes (sorted highest
130+
to lowest) will be shown.
131+
132+
The cut-off is applied per item (e.g. per reference flow or impact category, see [compare](#compare)) below).
133+
This means that if you want to see the top 5 contributors, you will only see the top 5 per item, even if a contributor would
134+
also be present for another item.
121135

122136
You can adjust the `Cut-off level` to change how many results you see.
123137

@@ -147,22 +161,22 @@ You can disable one of them if you want to focus on the other.
147161

148162
#### Relative and Absolute
149163
You can choose between `Relative` and `Absolute` results.
150-
The `Relative` results will sum to 100% (the total `Range` or `Score`),
164+
The `Relative` results will sum to 100% (the total `Score` or `Range`),
151165
the `Absolute` results will sum to the impact score.
152-
For `Relative`, you can choose what you use as the 100% reference, the `Range` or the `Score`.
166+
For `Relative`, you can choose what you use as the 100% reference, the `Score` or the `Range`.
153167

154-
#### Range and Score
155-
The `Range`/`Score` determines what you use as the _total_ to which the contributions are counted.
156-
- For `Range`, this is the full _range_ of results
157-
- For example, if all your negative results together have a score of -2 and all your positive results together have a
158-
score of 10, the _range_ is 12 (-2 * -1 + 10).
159-
- An entity with a contribution of 4 would have a relative contribution of 4/12 = 33.3...%.
168+
#### Score and Range
169+
The `Score`/`Range` determines what you use as the _total_ to which the contributions are counted.
160170
- For `Score`, this is the total score (sum) of the results
161171
- For example, if all your negative results together have a score of -2 and all your positive results together have a
162172
score of 10, the _score_ is 8 (-2 + 10).
163173
- An entity with a contribution of 4 would have a relative contribution of 4/8 = 50%.
174+
- For `Range`, this is the full _range_ of results
175+
- For example, if all your negative results together have a score of -2 and all your positive results together have a
176+
score of 10, the _range_ is 12 (-2 * -1 + 10).
177+
- An entity with a contribution of 4 would have a relative contribution of 4/12 = 33.3...%.
164178

165-
The `Range` or `Score` setting are only relevant when your results contain both positive and negative contributions.
179+
The `Score` or `Range` setting are only relevant when your results contain both positive and negative contributions.
166180

167181
### Positive and negative numbers in contribution results
168182
It can happen in LCA that you get both positive and negative numbers in your contribution results.
@@ -179,9 +193,11 @@ Below is a simple example (with unrealistic values) to demonstrate this:
179193
## Sankey
180194
The `Sankey` tab shows results from [graph traversal](https://docs.brightway.dev/projects/graphtools/en/latest/index.html).
181195
Graph traversal calculates results step-by-step for _nodes_ (activites) in the _graph_ (supply chain/product system).
196+
This is explained in detail by
197+
[van der Meide et al. (2025)](https://doi.org/10.31219/osf.io/sfgj6_v1) (path contributions).
182198

183199
### Sankey configuration
184-
In the `Sankey` tab, you can configure, you can set the
200+
In the `Sankey` tab, you can set the
185201
Reference flow, Impact category and Scenario (only available in scenario LCA, see [scenarios](#scenarios)) to be shown.
186202
you can also set a `cutoff` and `calculation depth` setting.
187203

29.1 KB
Loading
18 KB
Loading

activity_browser/layouts/pages/lca_results/LCA_results.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1539,7 +1539,10 @@ def data_to_df(self, all_data: List[list], compare: str) -> pd.DataFrame:
15391539

15401540
all_data[i] = item, data, col_name
15411541

1542-
self.unit = get_unit(self.parent.method_dict[self.combobox_menu.method.currentText()], self.relative)
1542+
if compare == "Impact Categories":
1543+
self.unit = get_unit(method=False, relative=self.relative)
1544+
else:
1545+
self.unit = get_unit(self.parent.method_dict[self.combobox_menu.method.currentText()], self.relative)
15431546

15441547
# convert to dict format to feed into dataframe
15451548
for key in unique_keys:

activity_browser/logger.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def __init__(self):
4343
log_file_location = self.filepath
4444

4545
# create the logfile and write the headers
46-
with open(self.filepath, "a", encoding="utf-8") as log_file:
46+
with open(self.filepath, "a", encoding='utf-8') as log_file:
4747
log_file.write(";".join(self.headers) + "\n")
4848

4949
def emit(self, record: logging.LogRecord):
@@ -52,7 +52,7 @@ def emit(self, record: logging.LogRecord):
5252
message = self.format(record)
5353

5454
# append to the logfile
55-
with open(self.filepath, "a", encoding="utf-8") as log_file:
55+
with open(self.filepath, "a", encoding='utf-8') as log_file:
5656
log_file.write(message)
5757

5858
# if there's exception info, write the exception traceback to the file as well
@@ -136,7 +136,7 @@ def emit(self, record: logging.LogRecord):
136136
message = self.format_log(record)
137137

138138
# append to the logfile
139-
with open(self.filepath, "a") as log_file:
139+
with open(self.filepath, "a", encoding='utf-8') as log_file:
140140
log_file.write(message)
141141

142142
# if there's exception info, write the exception traceback to the file as well

activity_browser/mod/bw2analyzer/contribution.py

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,22 @@ def sort_array(self, data: np.array, limit: float = 25, limit_type: str = "numbe
1212
See PR above on why we overwrite this function.
1313
"""
1414
if not total:
15-
abs_total_flag = True
1615
total = np.abs(data).sum()
17-
else:
18-
abs_total_flag = False
1916

2017
if total == 0 and limit_type == "cum_percent":
21-
raise ValueError("Cumulative percentage cannot be calculated to a total of 0, use a different limit type or total")
18+
raise ValueError(
19+
"Cumulative percentage cannot be calculated to a total of 0, use a different limit type or total")
2220

2321
if limit_type not in ("number", "percent", "cum_percent"):
2422
raise ValueError(f"limit_type must be either 'number', 'percent' or 'cum_percent' not '{limit_type}'.")
25-
if limit_type in ("percent", "cum_percent"):
23+
if limit_type in ("percent", "cum_percent"):
2624
if not 0 < limit <= 1:
2725
raise ValueError("Percentage limits > 0 and <= 1.")
26+
if limit_type == "number":
27+
if not int(limit) == limit:
28+
raise ValueError("Number limit must a whole number.")
29+
if not 0 < limit:
30+
raise ValueError("Number limit must be < 0.")
2831

2932
results = np.hstack(
3033
(data.reshape((-1, 1)), np.arange(data.shape[0]).reshape((-1, 1)))
@@ -38,30 +41,10 @@ def sort_array(self, data: np.array, limit: float = 25, limit_type: str = "numbe
3841
limit = (np.abs(data) >= (abs(total) * limit))
3942
results = results[limit, :]
4043
return results[np.argsort(np.abs(results[:, 0]))[::-1]]
41-
elif limit_type == "cum_percent" and abs_total_flag:
44+
elif limit_type == "cum_percent":
4245
# if we would apply this on the 'correct' order, this would stop just before the limit,
4346
# we want to be on or the first step over the limit.
4447
results = results[np.argsort(np.abs(data))] # sort low to high impact
4548
cumsum = np.cumsum(np.abs(results[:, 0])) / abs(total)
4649
limit = (cumsum >= (1 - limit)) # find items under limit
4750
return results[limit, :][::-1] # drop items under limit and set correct order
48-
elif limit_type == "cum_percent" and not abs_total_flag:
49-
# iterate over positive and negative values until limit is achieved or surpassed.
50-
results = results[np.argsort(np.abs(data))][::-1]
51-
pos_neg = [ # split into positive and negative sections
52-
results[results[:, 0] > 0],
53-
results[results[:, 0] < 0],
54-
]
55-
# iterate over positive and negative sections
56-
for i, arr in enumerate(pos_neg):
57-
c = 0
58-
# iterate over array until we have equalled or surpassed limit
59-
for j, row in enumerate(arr):
60-
c += abs(row[0] / total)
61-
if c >= limit:
62-
break
63-
arr = arr[:min(j + 1, len(arr)), :]
64-
pos_neg[i] = arr
65-
66-
results = np.concatenate(pos_neg) # rebuild into 1 array
67-
return results[np.argsort(np.abs(results[:, 0]))][::-1] # sort values

activity_browser/ui/web/navigator.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
from qtpy.QtCore import Slot
1111

1212
from activity_browser import signals
13-
from bw2data import Database, get_activity
13+
from bw2data import Database, get_activity, databases, Edge
14+
from bw2data.backends import ExchangeDataset, ActivityDataset
1415

15-
from ...bwutils.commontasks import identify_activity_type
16+
from ...bwutils.commontasks import identify_activity_type, get_activity_name
1617
from .base import BaseGraph, BaseNavigatorWidget
1718

1819
log = getLogger(__name__)
@@ -60,6 +61,9 @@ class GraphNavigatorWidget(BaseNavigatorWidget):
6061

6162
def __init__(self, parent=None, key=None):
6263
super().__init__(parent, css_file="navigator.css")
64+
self.setObjectName(get_activity_name(get_activity(key), str_length=30))
65+
self.key = key
66+
self.tab = parent
6367

6468
self.graph = Graph()
6569

@@ -113,6 +117,17 @@ def connect_signals(self):
113117
self.update_graph_settings
114118
)
115119
self.checkbox_flip_negative_edges.stateChanged.connect(self.reload_graph)
120+
databases.metadata_changed.connect(self.sync_graph)
121+
122+
def sync_graph(self):
123+
"""Sync the graph with the current project."""
124+
self.graph.update(delete_unstacked=False)
125+
self.send_json()
126+
try:
127+
self.setObjectName(get_activity_name(get_activity(self.key), str_length=30))
128+
except ActivityDataset.DoesNotExist:
129+
log.debug("Graph activity no longer exists. Closing tab.")
130+
self.tab.close_tab_by_tab_name(self.tab.get_tab_name(self))
116131

117132
def construct_layout(self) -> None:
118133
"""Layout of Graph Navigator"""
@@ -251,9 +266,24 @@ def __init__(self):
251266
self.flip_negative_edges = False # show true flow direction of edges (e.g. for ecoinvent treatment activities, or substitutions)
252267

253268
def update(self, delete_unstacked: bool = True) -> None:
269+
self.update_datasets()
254270
super().update(delete_unstacked)
255271
self.json_data = self.get_json_data()
256272

273+
def update_datasets(self):
274+
"""Update the activities in the graph."""
275+
try:
276+
self.nodes = [get_activity(act.key) for act in self.nodes]
277+
self.edges = [Edge(document=ExchangeDataset.get_by_id(exc._document.id)) for exc in self.edges]
278+
except (ActivityDataset.DoesNotExist, ExchangeDataset.DoesNotExist):
279+
try:
280+
get_activity(self.central_activity.key) # test whether the activity still exists
281+
self.new_graph(self.central_activity.key) # if so, create a new graph
282+
except ActivityDataset.DoesNotExist:
283+
log.warning("Graph activity no longer exists.")
284+
self.nodes = []
285+
self.edges = []
286+
257287
def store_previous(self) -> None:
258288
self.stack.append((deepcopy(self.nodes), deepcopy(self.edges)))
259289

0 commit comments

Comments
 (0)