From d54104e997d5cce5c16d2998b53436a2c9b66cad Mon Sep 17 00:00:00 2001
From: bsteubing 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 xWCx
zg2QE&Yo9-q@KaGCd{Q29|6Ap+ATemplate...
"
"# 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/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/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 ccf709a80bb3354f9a66e3896748c82786ce199f..1135a6238627aceb8d85ea29d43a209db02a2705 100644 GIT binary patch delta 1972 zcmY*ac{~(a8=e`lj7ytlZ0elT(b4uZC zVLgsH+fNh3QpOYrarHvXbDah8Xiy*|{x4%y@3gz$sMIRV%F`L3CT(|PutgFv9B7Zv zKKPjXTe54_`E(A)db29gAVHD097t&DnCs+13H73t15Hg+RIdvuYB8eBCy~p@x)ezk zh_kkr?kzMisMecF7u@t)c`Z~V;S$5Fz~sUj{c=^k{;-T7JHxo-LYyZ014s2&NA<`N z;JoNth#xc9EGh4;>GA2-8DDDS(qM8mheT;-u&kFQK9;mpyRSyL>-f7d=E==3noY^K zYCJS_S{Cf7(-__U5H=jP(3U1F(VS>s@1Zpqlr#O)G@RXzcg^9d*wCuTrQP}a0SCEj zG^{GxIaM@NrT%H&UjnnE7+}jq3im5niQ()9(~#XN;}*sGMQxQ+3RhpCd~ zhD#Bq@+m#|h;^-Ru0E^X>8QICHs%dI1wWdbgRNCnPkye~5Qxa*JG}bz`W!wO4sJ&TAWT z4*j-6L|<0xs4+gqicO8edVy-|!}J2LxU}?ryKTDoF(>;*x;9zq8<+jwmU5<)RUF(u zjX+SI9L$4!m&J@1sA%-X{H*ZpqOfMY6Ieu#%IKHNy_&qyyfS>=kBhlOxNeaG_Nj4+ zzN)08>$Qw_E6_6gkIbwAw{2ljmV8r}Q#{+@>b&>S_mz>3iG1jF{>8o6K0HKzA2VAy z9?q{y2~j1q2SxYCEL=lw=9R#AK?Afx6rt=|JKJ-Jotfl-xTWKcdig`#ndu|(u&G36 zlPSC(&Nn-buW4Cr%1A9D1Qagg6bEV)1^mepFds9Tt;Fn>k>Oab{9ky{yx?67a_uZ? z2)U@gZ4Qg5v?7r!biOX{cIhP7hE6tw;$<~PoNptOz4#|YL~Rwric{mbLYn3p2ruU# zd)F>kzuAs_>?gaX&x^v>eT<$7W%b}8d7#z0FFoK)zo&QqV^&`8Axm1X)}0L&HXI|i z8H!^xCz9^nNZhuosWh8Z#$`F1gxx&TUK^&7)T|jRJz!LC>6+ $SZWj1aZcCf14(A6{^4NqzC$cAE zS+sGcL2){owVy(8@db*dhFe>bb+|U%P6Zn5^j=cEw$n07O(0Gx@%%ea*~VPz72hnY z=)^kZA+uokXm;VIaYv(}Xt+pM`@_hKVgVq`xr&Io?gb8arX)`L(j<5r0fD+Wey|>H z fgkHA(g z`M}ag$BE(l&5&Yb0y@l9eP54b)Tl>XU@xX?zi>^AQ_D6qAB1;D>=g+`8OpX1bICc@ za#xIUdzsI;uEJCiz_i{*&8#x-EGb}d-=^EsPu9;E5afPRs1RW(rfYfrS*iQs;DaO2 zadOlT%b!2v4JA#Ipq;~?nxF>-+VjDp&aoftj{pfdG#16m CUPvXN7VhYAs_(ojtKzZqqF?8YNB?*BrX;tDGtH@tMLC? zOrPa+;UO!%5KihvKu-v!=>#04eT1OhF+oN`gJ3JYD-ZoCkbk< e$LQVE~TuNnP{B^rh35L>!u W@SK8NfRn7@a1skYH*-Ai@y*`?(`f+! delta 2012 zcmZ9Nc{r5o8^>R}vTH17> nJ2G26J){64N1$bs{sSh%%N@ifFtHjc7 z^^`=`5i+JTmQFIV45k?pexpuTS3Q3`_j}#f=f0o&{r>U2?-&;tOFAP31R($bhyvln zO%zeGW!qLP^iKVtN*vyUhn%w(Z@cQSBhR#L4~!vCEq0kKqtjoL9{MMWj8BiX*ldze z_AdkGF!Sgu1*Vs&3^(%#rVf?Vfr(eI3qE4+btNqC !g5rFhBi`8U|SpVAI0Qm#>+u#Ao1GbMOE}^$u<1_coRyJOw5jEAvb}_ z6Ps-um6Y_}OZ`dZA>`uDSf5ydq&iwxkqvy<{KjKhdKqP(in3GD>nZn*m70s`uk?G` zF)?Z%&UDU4$8}ZDHg&}i7nDZ^9Upyaaj=?7Tb!LhOdfBo3LYzxO$bDsj?q;hG)RTP z;dero$lV}y2%^&T&-O t8OAz8QLK6f4 zjsz$r`WtAlT?9`Lr&~y`XD58l1k%qK!RzeB>qD-|kMP^RJztrTW?D;dE+!REMz_QT z>7HDw6>XBbs*SaHV-i%F_pq|B;s~4NwR !zNbkCoJj(mcz&!pykujYPLvqA2hDz9tdr!SJ z#U|}$+mJ<&XJ6YQU4}|e1( MKsaaoS(ASg)Y$vQ8u0+|bpOkBEJT|or zQcrrglo5zu6Izotbj5V)MczTJNYS0F@`$v?bBw_z{-viCIPPwrHTLffNq@6;GT%?O zllP3M%ZYb$y@WwXNUto?DO$RC$T9Mm2V)@o==Sx=#kf@_eZ6N8l=<`K6dUnML`-uv zVB%u`MOD*OxGBB_o7Clk^!| 5v_t)NuB$F|QZ3M8DgWz-l_*TxWX z_}hPTGt0yBMG|q|Xc6~>(Gwq4DBVckyet@cW> FFY&9<=nmGQbbfGfGTCLv|J-y z)nsQ&HE0?f--V_AN^Rl+FT}s^k>_cNv>8mE)YDR=AI?Ep0BtUNOg@Ngh>Ctbckihw zQCK0;zZjg=yuS}QQGPif -JsdG3h-@=(BS49D<<28rg? lqEy6pqazxdWOfK_AkX2 z3o9G`L|dKGS`1vsFw4r7c;?5!L+@WtM;I-orYs}h9F@hEEqMrWN(@+GCVU9DL)*0c z1?~)&q#Qb)2GeXa3T6m7>vT=}J}n6$3*OSxO2uFiVd*GXZ^qPxVgOzSC(Mq^bF2+RjVv^JZ-ybuG(pw^Ae3s7Gr*IXn89I1hveYuLMQ zs!wpU)x|1Ejz;U1#W{V`y2)hivbbB_p88saDHetOrhcO^A8z$IZ{vJ+op7j1?tCom zLdYeX3`PR1_n|qtv<^=(S+T^3>^y~#M`6ax=?&x7j?h=5(YV+Ft7}(@_uHs>If@QP zyT>2}swmcK>a@~OVUeIN3Y^t%(t(W3R}3^ozb2HggNwKf<^hpIfu8Z4H ?MuxFu9c0&tEEQ$M&lDGtL|kh^m$5ZtU%D=4ezu4(3E8)) zL8vUFB4QHPkgc&tAzJRw &8j5@7870M b5f??J7d;tGMy#);o9;8RGGY60=GJs8*g0siazaI4FNq3X1T>P;{!pXrYoZ+ z_0fk);zAIgrhRWK`fM`aFgvFMw%b^PY0Xgis%N2SZ=1*@GV9^49s%!SZ0?!uHH&Fa z3u(D}%&23*X`AVJ;&~Xv-}j!wp0xbqTTZmA27|$c%)ib?@jHsGI@`&;T9G-oGaq~X zOY!QSudcN#Nj6-YmRmwaz8m!fb*#3Bes`Kj{Ci`KD2^#EFnec>Af8PSlwHjlSpcnI z00<^Qju0j=Lf^7tw!687>7+YW&|c!~)%=otb0MY4-q=j u>HaxLLm_p9!rLLJ-r#)P z6KZ)QsXi9+k^-1h9lFqkwIrR^uIkS{EHlBvu8bV$ysn;ZNTI*C#`(wNbPbGdJ aVFo;4fS0UHJx4i>xWG(sFaxn9BR#* zATpy?g}YDk+kqxUgd637GkzKGE72mB^C>P}-egdeDvurSJ@zbP{v@4Knc8pg(7*b| zQLW*T8ppTstqGX^_!Hzb9On6}yH3W;nrmx;h)gr3P ?M{MOyv0S)wvBl=}lwwp^ <*a!m%LChNp4&hVs$mkAcbDiwM>QG>J>tXFYsC8xrI z)D&_ihphr-kPd5RhnAZ-h4I9wr7FVaMU#Y%p_R7uadW;>j>D-GeIJ3Vnsjg*;W^f< zVy?q6&*SpZ8Oy ofDs#p k%)ztDYct{VJQ?neXr3T>D^>gWB7o1wTZBsm=1uZAt9KZOtUL)! z(3}Qb(`Eh>F-OmY*EI0AWD&FtylO))=;R9 JayD| z=KGpt+jPWlDffHFP&@9h!M4ml-*Kxs1SD%4ueVd4-Ly_Q^;ynsRC`eZ>RoBmxsAn( z+ZigJJZq%l4C~YGwahvBV$oJ?Wk@PnQ)~g1Kbs=B5MI4U_rxy@Mb{Ft-@2N-QLtRJ zC%%;ITKA~~1M&Jag6mY**6QomtFz)PXO*no3U_CzBwWAK9%RfaZctfy6|b3CO-i_> zrC?Nj{gu9pHPoWRr !E5r r^QQy$dCXUaKYb&@?|Fmp${}>~iS#yfGNyTl>7i>K0V7zIor!pw*0e{YI=_(% z==w^5r?Dd1x6rwt?LWZv!GX%BPkrv$LjEqmJ%%iF2s|Zt4rCsym-;s}$e0w$hZK%nS!me)Om5i~y?!Mtm4=z*8 zzE#)G{Gk^L8VFQ4?&rE9`uIzJ^H;%t>~5ZIa?1iV4luhSSHR|f6pbX|9MC~a9nLyv z9Tv3){V+wcuh{W_E(*C?OkbMqkc(vs4;#1u-~ti=ME0-G-$4Yy$s7nd8McPfkd)X7 z0)Pch01*FMu%BCEb`FsRc_hfL2>u_!pLPrVf8i?czEG50gn&u2N8ZiG9U*f7fFVBs z>}!7p5yT`9A|wy~u#S)b6_6p~5-x0{MZ=DDZGVXO` %O1+xqg4#_b0 E8(H609BD^RCCWS>^O$Jt~5125Y-=LMG3sO;438*H3qvF5_?pZ!!LJNpxy%l4bGj z7U6ii|3c^@G3k!c<$mrd;}D*D|E-Qt+MY*et&iiA0`vYmrf?*Xhn`CKf>21%J=yM^ zmLRQKDEK^R&oTeIs#(gyLtm3|VJ%PcnUw4dnk?!ef~E#5s^-i|Y*5Q-2MgnOQe>Ki zPYG7xzO}6>C8zdY?2G>+AeY`5;}s(q|7f(U$O1iVoK`8#DJwjzq-b9>_zwG(|L{WU z3*DaQp_JXTq|U{t*shxSrY=hQk|O(+E%OuW1m@lK5A&~Wrd?X9&rTLe$DOwEp=cr# zo=66w(ZvBPx!qv@5JIVGLY!Fv YQ0Y7i6X0O1cu+a9tYJ|mBAQIa*=ENYZ zSqK2G!9hu}U0@+nn3{;=1kD_RZOXQO%m5flrab?vMwXOOo_F1~qJT36Seq-gnR+)= zH5MlIwY+AX->O{E8+@Zejw
""" @@ -118,7 +118,7 @@ class ContributionTreeCacheEntry: class ContributionTreeTab(QtWidgets.QWidget): """Contribution Tree tab for the LCA Results page. - Shows a QTreeView (lazy, expandable by tier) with a sunburst plot above. + Shows a QTreeView (lazy, expandable by tier) with an optional supply-chain plot. Cache key: (fu_index, method_index, scenario_index, cutoff_percent). Each entry stores the Brightway traversal and the set of expanded row uids so switching RF / IC / scenario restores both calculation and open branches. @@ -158,24 +158,36 @@ def __init__(self, parent=None): "is below this percent of the total LCA score" ) - self.plot_depth_sb = QtWidgets.QSpinBox() - self.plot_depth_sb.setRange(1, 20) - self.plot_depth_sb.setValue(3) - self.plot_depth_sb.setToolTip("Number of tiers shown in the sunburst plot") + self.plot_type_cb = SmallComboBox() + for mode_id, label in PLOT_MODES: + self.plot_type_cb.addItem(label, mode_id) + for i in range(self.plot_type_cb.count()): + if self.plot_type_cb.itemData(i) == PLOT_ICICLE: + self.plot_type_cb.setCurrentIndex(i) + break + self.plot_type_cb.setToolTip("Contribution tree visualization type") + + self.aggregate_by_cb = SmallComboBox() + self.aggregate_by_cb.addItem("None", None) + for field in PLOT_AGGREGATE_FIELDS: + self.aggregate_by_cb.addItem(PLOT_AGGREGATE_LABELS[field], field) + self.aggregate_by_cb.setToolTip( + "Roll up sibling plot segments by metadata (plot only; table unchanged)" + ) self.expand_mode_cb = SmallComboBox() self.expand_mode_cb.addItem("Tier", EXPAND_MODE_TIER) self.expand_mode_cb.addItem("Individual path impact", EXPAND_MODE_PATH) self.expand_mode_cb.addItem("Cumulative impact", EXPAND_MODE_CUMULATIVE) - self.expand_mode_cb.setToolTip("How far auto-expand calculates and opens the tree") + self.expand_mode_cb.setToolTip("How far Adjust calculates and opens the tree") self.expand_value_sb = QtWidgets.QDoubleSpinBox() self.expand_value_sb.setKeyboardTracking(False) - self.expand_btn = QtWidgets.QPushButton("Expand") - self.expand_btn.setToolTip("Calculate and open branches according to the expand policy") + self.expand_btn = QtWidgets.QPushButton("Adjust") + self.expand_btn.setToolTip("Calculate and open branches according to the adjust policy") self.show_plot_cb = QtWidgets.QCheckBox("Show plot") - self.show_plot_cb.setChecked(False) + self.show_plot_cb.setChecked(True) self.show_table_cb = QtWidgets.QCheckBox("Show table") self.show_table_cb.setChecked(True) self._last_expand_target_pct: float | None = None @@ -234,8 +246,8 @@ def __init__(self, parent=None): for col, d in self._delegates.items(): self._tree_view.setItemDelegateForColumn(col, d) - # --- Sunburst plot --- - self._plot = SunburstPlot(self) + # --- Contribution tree plot --- + self._plot = ContributionTreePlot(self) self._plot.setMinimumHeight(180) self._tree_view.setMinimumHeight(120) @@ -267,8 +279,11 @@ def _update_view_visibility(self, *_args) -> None: """Show or hide plot/table and redistribute splitter space.""" show_plot = self.show_plot_cb.isChecked() show_table = self.show_table_cb.isChecked() + was_hidden = not self._plot.isVisible() self._plot.setVisible(show_plot) self._tree_view.setVisible(show_table) + if show_plot and was_hidden and self._current_state is not None: + self._reload_plot() QtCore.QTimer.singleShot(0, self._apply_splitter_sizes) def _apply_splitter_sizes(self) -> None: @@ -298,10 +313,10 @@ def _build_layout(self) -> None: # Header + help help_btn = lca_help_tool_button( self, - "Left click for help on the Contribution Tree", + "Left click for help on the Tree tab", self._show_help, ) - main.addLayout(lca_header_layout("Contribution Tree", help_btn)) + main.addLayout(lca_header_layout("Tree", help_btn)) # Control row 1: FU / method / scenario (left-aligned like other LCA tabs) row1 = lca_tab_control_row() @@ -314,21 +329,24 @@ def _build_layout(self) -> None: row1.addStretch() main.addLayout(row1) - # Control row 2: cutoff / plot tiers / expand policy + # Control row 2: plot/table, cutoff, expand, plot type row2 = lca_tab_control_row() + row2.addWidget(self.show_plot_cb) + row2.addWidget(self.show_table_cb) + row2.addSpacing(12) row2.addWidget(QtWidgets.QLabel("Cutoff:")) row2.addWidget(self.cutoff_sb) row2.addSpacing(12) - row2.addWidget(QtWidgets.QLabel("Plot tiers:")) - row2.addWidget(self.plot_depth_sb) - row2.addSpacing(12) - row2.addWidget(QtWidgets.QLabel("Expand to:")) + row2.addWidget(QtWidgets.QLabel("Adjust to:")) row2.addWidget(self.expand_mode_cb) row2.addWidget(self.expand_value_sb) row2.addWidget(self.expand_btn) row2.addSpacing(12) - row2.addWidget(self.show_plot_cb) - row2.addWidget(self.show_table_cb) + row2.addWidget(QtWidgets.QLabel("Plot:")) + row2.addWidget(self.plot_type_cb) + row2.addSpacing(12) + row2.addWidget(QtWidgets.QLabel("Aggregate by:")) + row2.addWidget(self.aggregate_by_cb) row2.addStretch() main.addLayout(row2) @@ -352,9 +370,11 @@ def _connect_signals(self) -> None: self.method_cb.currentIndexChanged.connect(self._on_selection_changed) self.scenario_cb.currentIndexChanged.connect(self._on_selection_changed) self.cutoff_sb.valueChanged.connect(self._on_cutoff_changed) - self.plot_depth_sb.valueChanged.connect(self._on_plot_depth_changed) + self.plot_type_cb.currentIndexChanged.connect(self._on_plot_type_changed) + self.aggregate_by_cb.currentIndexChanged.connect(self._on_aggregate_by_changed) self.expand_mode_cb.currentIndexChanged.connect(self._on_expand_mode_changed) self.expand_btn.clicked.connect(self._on_expand_clicked) + self._plot.set_segment_click_handler(self._on_plot_segment_clicked) self.show_plot_cb.toggled.connect(self._update_view_visibility) self.show_table_cb.toggled.connect(self._update_view_visibility) # Queued so model mutations do not run inside QTreeView's expand stack @@ -538,7 +558,6 @@ def _run_traversal(self) -> None: expanded_uids=entry.expanded_uids, model_uids=entry.model_uids, ) - self._fit_cumulative_column() return logger.debug(f"Contribution tree traversal start: {key}") @@ -567,7 +586,6 @@ def _run_traversal(self) -> None: self._busy_tick(progress, "Building tree…") self._reload_from_state(expanded_uids=None, model_uids=None) self._save_view_snapshot() - self._fit_cumulative_column() except Exception as exc: logger.exception("Contribution tree traversal failed") @@ -594,16 +612,21 @@ def _reload_from_state( if state is None: return total = self._state_total_score(state) - self._tree_model.load_state(state, total) - if model_uids is not None: - self._tree_model.restrict_to_uids(model_uids) - self._update_delegate_maxima() - self._tree_view.collapseAll() - if expanded_uids: - self._restore_expanded_uids(expanded_uids) - self._reload_plot() - self._update_footer_stats() + self._tree_view.setUpdatesEnabled(False) + try: + self._tree_model.load_state(state, total, included_uids=model_uids) + self._update_delegate_maxima() + self._tree_view.collapseAll() + if expanded_uids: + self._restore_expanded_uids(expanded_uids) + if self.show_plot_cb.isChecked(): + self._reload_plot() + self._update_footer_stats() + finally: + self._tree_view.setUpdatesEnabled(True) + self._tree_view.viewport().update() QtCore.QTimer.singleShot(0, self._apply_splitter_sizes) + QtCore.QTimer.singleShot(0, self._fit_cumulative_column) def _collect_expanded_uids(self) -> set[int]: """Return unique_ids of rows currently expanded in the tree view.""" @@ -661,10 +684,65 @@ def _on_cutoff_changed(self) -> None: self._active_cache_key = None self._run_traversal() - @Slot(int) - def _on_plot_depth_changed(self, depth: int) -> None: + @Slot() + def _on_plot_type_changed(self) -> None: + mode = self.plot_type_cb.currentData() + if mode: + self._plot.set_mode(mode) + + @Slot() + def _on_aggregate_by_changed(self) -> None: self._reload_plot() + @Slot() + def _on_plot_segment_clicked(self, segment: dict) -> None: + """Expand/collapse tree branch from plot click; select matching row.""" + if self._current_state is None: + return + uid = plot_click_target_uid(segment) + state = self._current_state + if is_terminal_node(state.nodes, state.edges, state.visited_nodes, uid): + self._expand_tree_node_only(uid) + else: + self._toggle_tree_node(uid) + + def _select_tree_row(self, uid: int) -> QtCore.QModelIndex | None: + item = self._tree_model.item_for_uid(uid) + if item is None: + return None + idx = self._tree_model.indexFromItem(item) + if not idx.isValid(): + return None + sm = self._tree_view.selectionModel() + if sm is not None: + sm.select( + idx, + QtCore.QItemSelectionModel.SelectionFlag.ClearAndSelect + | QtCore.QItemSelectionModel.SelectionFlag.Rows, + ) + self._tree_view.scrollTo( + idx, + QtWidgets.QAbstractItemView.ScrollHint.PositionAtCenter, + ) + return idx + + def _expand_tree_node_only(self, uid: int) -> None: + """Expand-only (terminal segments): traverse once; never collapse from plot.""" + idx = self._select_tree_row(uid) + if idx is None or not idx.isValid(): + return + if not self._tree_view.isExpanded(idx): + self._tree_view.expand(idx) + + def _toggle_tree_node(self, uid: int) -> None: + idx = self._select_tree_row(uid) + if idx is None or not idx.isValid(): + return + if self._tree_view.isExpanded(idx): + self._tree_view.collapse(idx) + else: + self._tree_view.expand(idx) + @Slot() def _on_expand_mode_changed(self) -> None: self._apply_expand_mode_defaults(reset_value=True) @@ -717,7 +795,7 @@ def _on_expand_clicked(self) -> None: self._store_total_score(state) total = self._state_total_score(state) - progress = self._busy_dialog("Expanding contribution tree…") + progress = self._busy_dialog("Adjusting contribution tree…") self._last_expand_target_pct = ( value if mode in (EXPAND_MODE_PATH, EXPAND_MODE_CUMULATIVE) else None ) @@ -737,21 +815,24 @@ def _tick(step, n_nodes): ) self._busy_tick(progress, "Building tree…") - self._tree_model.load_state(state, total) - if included is not None: - self._tree_model.restrict_to_uids(included) - - self._busy_tick(progress, "Updating tree view…") - self._update_delegate_maxima() - if mode == EXPAND_MODE_TIER: - self._apply_expand_view_state(max_tier=int(value)) - elif to_expand is not None: - self._restore_expanded_uids(to_expand) - if self.show_plot_cb.isChecked(): - self._busy_tick(progress, "Updating plot…") - self._reload_plot() - self._update_footer_stats() - self._fit_cumulative_column() + self._tree_view.setUpdatesEnabled(False) + try: + self._tree_model.load_state(state, total, included_uids=included) + + self._busy_tick(progress, "Updating tree view…") + self._update_delegate_maxima() + if mode == EXPAND_MODE_TIER: + self._apply_expand_view_state(max_tier=int(value)) + elif to_expand is not None: + self._restore_expanded_uids(to_expand) + if self.show_plot_cb.isChecked(): + self._busy_tick(progress, "Updating plot…") + self._reload_plot() + self._update_footer_stats() + finally: + self._tree_view.setUpdatesEnabled(True) + self._tree_view.viewport().update() + QtCore.QTimer.singleShot(0, self._fit_cumulative_column) self._save_view_snapshot() finally: progress.close() @@ -760,6 +841,7 @@ def _tick(step, n_nodes): def _apply_expand_view_state(self, max_tier: int) -> None: """Collapse, then open rows with real children whose display tier is ``< max_tier``.""" self._suppress_expand_handler = True + self._tree_view.setUpdatesEnabled(False) try: self._tree_view.collapseAll() to_expand: list[tuple[int, QtGui.QStandardItem]] = [] @@ -776,6 +858,7 @@ def _apply_expand_view_state(self, max_tier: int) -> None: if idx.isValid(): self._tree_view.expand(idx) finally: + self._tree_view.setUpdatesEnabled(True) self._suppress_expand_handler = False @Slot() @@ -870,10 +953,7 @@ def _on_row_expanded(self, index: QtCore.QModelIndex) -> None: added = self._tree_model.expand_node(uid) if added or self._tree_model.has_real_children(first_col_item): self._update_delegate_maxima() - self._reload_plot() - self._update_footer_stats() - self._fit_cumulative_column() - self._save_view_snapshot() + self._refresh_view_from_tree() return # Leaf: collapse after the current event finishes @@ -890,22 +970,64 @@ def _on_row_collapsed(self, index: QtCore.QModelIndex) -> None: """Footer coverage is view-scoped; refresh when branches hide.""" if self._suppress_expand_handler: return - self._update_footer_stats() - self._save_view_snapshot() + self._refresh_view_from_tree() # ------------------------------------------------------------------ # Plot helpers # ------------------------------------------------------------------ - def _reload_plot(self) -> None: + def _refresh_view_from_tree(self) -> None: + row_stats = self._visible_row_stats() + if self.show_plot_cb.isChecked(): + self._reload_plot(row_stats) + self._update_footer_stats(row_stats) + + def _visible_row_stats(self) -> tuple[int, float, int, set[int]]: + """Single pass: shown count, direct coverage, max tier, visible uids.""" + state = self._current_state + if state is None: + return 0, 0.0, 0, set() + total = self._state_total_score(state) + shown_n = 0 + direct_sum = 0.0 + max_tier = 0 + visible_uids: set[int] = set() + for uid, item in self._tree_model.iter_uid_items(): + if not self._is_row_visible(item): + continue + shown_n += 1 + visible_uids.add(uid) + max_tier = max(max_tier, int(item.data(TIER_ROLE) or 0)) + node = state.nodes.get(uid) + if node is not None: + direct_sum += getattr(node, "direct_emissions_score", 0.0) + coverage = direct_sum / abs(total) if total else 0.0 + return shown_n, coverage, max_tier, visible_uids + + def _visible_tree_uids(self) -> set[int]: + """Unique ids of rows currently shown in the tree view.""" + return self._visible_row_stats()[3] + + def _reload_plot( + self, + row_stats: tuple[int, float, int, set[int]] | None = None, + ) -> None: if self._current_state is None: + self._plot.show_empty() return - if not self.show_plot_cb.isChecked(): - return + state = self._current_state + meta = state.metadata or {} + if row_stats is None: + row_stats = self._visible_row_stats() + _, _, max_tier, visible_uids = row_stats self._plot.set_state( - self._current_state, - self._state_total_score(self._current_state), - self.plot_depth_sb.value(), + state, + self._state_total_score(state), + max(1, max_tier + 1), + metadata_lookup=self._tree_model.lookup_activity_meta, + unit=meta.get("unit", ""), + included_uids=visible_uids, + aggregate_by=self.aggregate_by_cb.currentData(), ) # ------------------------------------------------------------------ @@ -924,22 +1046,21 @@ def _update_delegate_maxima(self) -> None: # Footer stats # ------------------------------------------------------------------ - def _update_footer_stats(self) -> None: + def _update_footer_stats( + self, + row_stats: tuple[int, float, int, set[int]] | None = None, + ) -> None: state = self._current_state if state is None: self._stats_label.setText("") return root_uid = self._tree_model.root_uid total = self._state_total_score(state) - shown_cov = self._visible_direct_impact_coverage() - shown_n = sum( - 1 - for _, item in self._tree_model.iter_uid_items() - if self._is_row_visible(item) - ) + if row_stats is None: + row_stats = self._visible_row_stats() + shown_n, shown_cov, shown_tier, _ = row_stats calc_cov = direct_impact_coverage(state.nodes, total, root_uid) calc_n = sum(1 for uid in state.nodes if uid != root_uid) - shown_tier = self._max_visible_tier() calc_tier = self._max_calculated_tier() calc_part = ( @@ -972,32 +1093,6 @@ def _is_row_visible(self, item: QtGui.QStandardItem) -> bool: parent = parent.parent() return True - def _visible_direct_impact_coverage(self) -> float: - """Σ(direct impact of visible rows) / |total| — same as summing the column.""" - state = self._current_state - if state is None: - return 0.0 - total = self._state_total_score(state) - if total == 0.0: - return 0.0 - direct_sum = 0.0 - for uid, item in self._tree_model.iter_uid_items(): - if not self._is_row_visible(item): - continue - node = state.nodes.get(uid) - if node is None: - continue - direct_sum += getattr(node, "direct_emissions_score", 0.0) - return direct_sum / abs(total) - - def _max_visible_tier(self) -> int: - """Deepest tier among rows whose ancestor chain is expanded in the view.""" - max_tier = 0 - for _, item in self._tree_model.iter_uid_items(): - if self._is_row_visible(item): - max_tier = max(max_tier, int(item.data(TIER_ROLE) or 0)) - return max_tier - def _max_calculated_tier(self) -> int: """Deepest edge-based display tier among all discovered traversal nodes.""" state = self._current_state @@ -1006,8 +1101,17 @@ def _max_calculated_tier(self) -> int: root_uid = self._tree_model.root_uid if root_uid is None: return 0 + meta = state.metadata + if meta is not None: + cached_n = meta.get("_max_tier_node_count") + if cached_n == len(state.nodes) and "max_tier" in meta: + return int(meta["max_tier"]) tiers = compute_node_tiers(state.nodes, state.edges, root_uid) - return max(tiers.values(), default=0) + result = max(tiers.values(), default=0) + if meta is not None: + meta["max_tier"] = result + meta["_max_tier_node_count"] = len(state.nodes) + return result # ------------------------------------------------------------------ # Export (Ticket 06) @@ -1051,16 +1155,17 @@ def _export_plot(self) -> None: if self.parent else "contribution_tree_plot" ) - path, _ = QtWidgets.QFileDialog.getSaveFileName( + file_filter = "SVG (*.svg);;PNG (*.png)" + path, selected_filter = QtWidgets.QFileDialog.getSaveFileName( self, - "Export Sunburst Plot", + "Export Contribution Tree Plot", default_name, - "SVG (*.svg);;PNG (*.png)", + file_filter, ) if not path: return try: - self._plot.figure.savefig(path, bbox_inches="tight") + self._plot.export_figure(path, selected_filter) logger.info(f"Contribution tree plot exported to {path}") except Exception as exc: QtWidgets.QMessageBox.warning(self, "Export failed", str(exc)) diff --git a/activity_browser/bwutils/contribution_tree.py b/activity_browser/bwutils/contribution_tree.py index 9916a3b17..8dce6e683 100644 --- a/activity_browser/bwutils/contribution_tree.py +++ b/activity_browser/bwutils/contribution_tree.py @@ -9,6 +9,7 @@ from __future__ import annotations import warnings +from collections import defaultdict from contextlib import contextmanager from typing import Callable @@ -529,6 +530,421 @@ def _above(uid: NodeId) -> bool: return included, to_expand +# --------------------------------------------------------------------------- +# Direct-impact colour (matches Contribution Tree table blue/green tint) +# --------------------------------------------------------------------------- + +def direct_impact_intensity( + value: float, + column_max: float, + *, + floor_ratio: float = 0.01, +) -> float: + """Map ``|value|`` to ``[0, 1]`` on a log10 axis — same curve as the table delegate.""" + import math + + if column_max <= 0 or value == 0: + return 0.0 + lo = max(abs(column_max) * floor_ratio, 1e-12) + hi = abs(column_max) + if lo >= hi: + return 1.0 if abs(value) >= hi else 0.0 + v = min(max(abs(value), lo), hi) + return (math.log10(v) - math.log10(lo)) / (math.log10(hi) - math.log10(lo)) + + +def direct_impact_rgba( + direct_pct: float, + max_direct_pct: float, +) -> tuple[float, float, float, float]: + """RGBA for plot segments — blue burdens, green credits.""" + frac = direct_impact_intensity(direct_pct, max_direct_pct) + alpha = 0.12 + 0.82 * frac + if direct_pct < 0: + return (85 / 255, 170 / 255, 95 / 255, alpha) + return (70 / 255, 130 / 255, 210 / 255, alpha) + + +def _parent_uid(child_uid: NodeId, edges: list, root_uid: NodeId) -> NodeId: + parent = _find_parent(child_uid, edges) + return root_uid if parent is None else parent + + +def _upstream_layout_span( + span: float, + cumulative_score: float, + direct_emissions_score: float, +) -> float: + """Horizontal span available for upstream children (excludes parent direct). + + Uses ``|cumulative − direct|`` so credits that make ``|direct| > |cumulative|`` + still receive a positive band (same magnitude as the net upstream share). + """ + if span <= 0 or not cumulative_score: + return 0.0 + upstream_abs = abs(cumulative_score - direct_emissions_score) + return span * (upstream_abs / abs(cumulative_score)) + + +# --------------------------------------------------------------------------- +# Supply-chain layout (sunburst / tier bars / icicle) +# --------------------------------------------------------------------------- + +def build_chain_layout( + nodes: dict, + edges: list, + total_score: float, + max_depth: int, + root_uid: NodeId | None = None, + metadata_lookup: Callable[[int], dict] | None = None, + included_uids: set[NodeId] | None = None, +) -> list[dict]: + """Parent-aligned layout segments for contribution-tree plots. + + Each segment has ``tier``, ``x0``/``x1`` in ``[0, 1]``, impact scores, + and activity metadata. Tier-0 (reference flow) nodes only — not every + virtual-root child. + + When ``included_uids`` is set, only those nodes appear and visible + siblings reflow within each parent's upstream (non-direct) span. + """ + if not nodes or total_score == 0.0 or max_depth <= 0: + return [] + + if root_uid is None: + roots = [n for n in nodes.values() if getattr(n, "depth", None) == 0] + if len(roots) != 1: + return [] + root_uid = roots[0].unique_id + + pcm = build_parent_child_map(nodes, edges) + tiers = compute_node_tiers(nodes, edges, root_uid) + segments: list[dict] = [] + + def is_included(uid: NodeId) -> bool: + return included_uids is None or uid in included_uids + + def meta_for(node) -> dict: + if metadata_lookup is None: + return {} + return metadata_lookup(getattr(node, "activity_datapackage_id", None)) or {} + + def append_segment(node, x0: float, x1: float) -> None: + if not is_included(node.unique_id): + return + tier = tiers.get(node.unique_id) + if tier is None or tier >= max_depth: + return + act_meta = meta_for(node) + product = act_meta.get("product") or getattr(node, "_label", str(node.unique_id)) + segments.append({ + "unique_id": node.unique_id, + "tier": tier, + "x0": x0, + "x1": x1, + "product": product, + "process": act_meta.get("name", ""), + "location": act_meta.get("location", ""), + "database": act_meta.get("database", ""), + "unit": act_meta.get("unit", ""), + "cumulative_score": node.cumulative_score, + "direct_emissions_score": node.direct_emissions_score, + "cumulative_pct": cumulative_percent(node, total_score), + "direct_pct": direct_percent(node, total_score), + }) + + def walk_children(parent_node, x0: float, x1: float, tier: int) -> None: + child_tier = tier + 1 + if child_tier >= max_depth: + return + children = [ + nodes[c] + for c in pcm.get(parent_node.unique_id, []) + if c in nodes and is_included(c) + ] + children.sort(key=lambda n: abs(n.cumulative_score), reverse=True) + if not children: + return + x = x0 + span = x1 - x0 + layout_span = _upstream_layout_span( + span, + parent_node.cumulative_score, + parent_node.direct_emissions_score, + ) + if included_uids is None: + denom = abs(parent_node.cumulative_score) + for child in children: + share = abs(child.cumulative_score) / denom if denom else 0.0 + w = share * span + append_segment(child, x, x + w) + walk_children(child, x, x + w, child_tier) + x += w + else: + child_total = sum(abs(c.cumulative_score) for c in children) + for child in children: + share = abs(child.cumulative_score) / child_total if child_total else 0.0 + w = share * layout_span + append_segment(child, x, x + w) + walk_children(child, x, x + w, child_tier) + x += w + + tier0 = [ + nodes[uid] + for uid in pcm.get(root_uid, []) + if uid in nodes and tiers.get(uid) == 0 and is_included(uid) + ] + tier0.sort(key=lambda n: abs(n.cumulative_score), reverse=True) + + x = 0.0 + if included_uids is None: + denom = abs(total_score) + for node in tier0: + share = abs(node.cumulative_score) / denom if denom else 0.0 + append_segment(node, x, x + share) + walk_children(node, x, x + share, 0) + x += share + else: + tier0_total = sum(abs(n.cumulative_score) for n in tier0) + for node in tier0: + share = ( + abs(node.cumulative_score) / tier0_total + if tier0_total + else 0.0 + ) + append_segment(node, x, x + share) + walk_children(node, x, x + share, 0) + x += share + + return segments + + +# Plot aggregation (sibling merge under each parent; plot-only display) +PLOT_AGGREGATE_FIELDS = ("product", "name", "location", "unit", "database") + +PLOT_AGGREGATE_LABELS = { + "product": "Product", + "name": "Process", + "location": "Location", + "unit": "Unit", + "database": "Database", +} + + +def plot_click_target_uid(segment: dict) -> int: + """Tree ``unique_id`` to toggle when a plot segment is clicked.""" + return int(segment.get("toggle_uid", segment["unique_id"])) + + +def is_terminal_node( + nodes: dict, + edges: list, + visited: set, + uid: NodeId, +) -> bool: + """True when a visited node has no downstream suppliers in the graph.""" + if uid not in visited: + return False + pcm = build_parent_child_map(nodes, edges) + return not pcm.get(uid) + + +def _aggregate_field_value(segment: dict, field: str) -> str: + if field == "product": + raw = segment.get("product") + elif field == "name": + raw = segment.get("process") + elif field == "location": + raw = segment.get("location") + elif field == "unit": + raw = segment.get("unit") + elif field == "database": + raw = segment.get("database") + else: + raw = "" + text = str(raw or "").strip() + return text or "(unknown)" + + +def _segment_with_toggle(segment: dict) -> dict: + out = dict(segment) + out.setdefault("is_aggregate", False) + out.setdefault("toggle_uid", segment["unique_id"]) + return out + + +def _parent_plot_span( + parent_uid: NodeId, + segments_by_uid: dict[int, dict], + root_uid: NodeId, +) -> tuple[float, float]: + if parent_uid == root_uid: + return 0.0, 1.0 + parent = segments_by_uid.get(parent_uid) + if parent is None: + return 0.0, 1.0 + return parent["x0"], parent["x1"] + + +def _merge_sibling_segments( + children: list[dict], + aggregate_by: str, + px0: float, + px1: float, + total_score: float, + parent_uid: NodeId, + segments_by_uid: dict[int, dict], + root_uid: NodeId, +) -> list[dict]: + groups: dict[str, list[dict]] = defaultdict(list) + for child in children: + groups[_aggregate_field_value(child, aggregate_by)].append(child) + + ordered = sorted( + groups.items(), + key=lambda pair: sum(abs(s["cumulative_score"]) for s in pair[1]), + reverse=True, + ) + span = px1 - px0 + if parent_uid == root_uid: + layout_span = span + else: + parent_seg = segments_by_uid.get(parent_uid) + if parent_seg is None: + layout_span = span + else: + layout_span = _upstream_layout_span( + span, + parent_seg["cumulative_score"], + parent_seg["direct_emissions_score"], + ) + total_mag = sum(sum(abs(s["cumulative_score"]) for s in grp) for _, grp in ordered) + x = px0 + merged: list[dict] = [] + for key, group in ordered: + group_cum = sum(s["cumulative_score"] for s in group) + group_direct = sum(s["direct_emissions_score"] for s in group) + group_mag = sum(abs(s["cumulative_score"]) for s in group) + share = group_mag / total_mag if total_mag else 0.0 + width = share * layout_span + constituent_uids = [s["unique_id"] for s in group] + is_aggregate = len(group) > 1 + if is_aggregate: + base = dict(group[0]) + base.update({ + "is_aggregate": True, + "toggle_uid": parent_uid, + "parent_unique_id": parent_uid, + "constituent_uids": constituent_uids, + "aggregate_key": key, + "aggregate_by": aggregate_by, + }) + else: + base = _segment_with_toggle(group[0]) + base["x0"] = x + base["x1"] = x + width + base["cumulative_score"] = group_cum + base["direct_emissions_score"] = group_direct + base["cumulative_pct"] = ( + (group_cum / total_score) * 100.0 if total_score else 0.0 + ) + base["direct_pct"] = ( + (group_direct / total_score) * 100.0 if total_score else 0.0 + ) + base["constituent_products"] = [ + s.get("product", "") for s in group if s.get("product") + ] + merged.append(base) + x += width + return merged + + +def aggregate_plot_segments( + segments: list[dict], + aggregate_by: str | None, + pcm: dict[NodeId, list[NodeId]], + root_uid: NodeId, + total_score: float, +) -> list[dict]: + """Merge sibling plot segments under each parent by a metadata field.""" + if not segments: + return [] + if not aggregate_by: + return [_segment_with_toggle(s) for s in segments] + + child_to_parent = { + child: parent for parent, kids in pcm.items() for child in kids + } + segments_by_uid = {s["unique_id"]: dict(s) for s in segments} + max_tier = max(s["tier"] for s in segments) + merged_by_tier: dict[int, list[dict]] = {} + + for tier in range(max_tier + 1): + tier_children = [dict(s) for s in segments if s["tier"] == tier] + by_parent: dict[NodeId, list[dict]] = defaultdict(list) + for seg in tier_children: + parent_uid = child_to_parent.get(seg["unique_id"], root_uid) + by_parent[parent_uid].append(seg) + + tier_merged: list[dict] = [] + for parent_uid, children in by_parent.items(): + px0, px1 = _parent_plot_span(parent_uid, segments_by_uid, root_uid) + tier_merged.extend( + _merge_sibling_segments( + children, + aggregate_by, + px0, + px1, + total_score, + parent_uid, + segments_by_uid, + root_uid, + ) + ) + merged_by_tier[tier] = tier_merged + for seg in tier_merged: + if not seg.get("is_aggregate"): + segments_by_uid[seg["unique_id"]] = seg + + out: list[dict] = [] + for tier in range(max_tier + 1): + out.extend(merged_by_tier.get(tier, [])) + return out + + +def build_plot_segments( + nodes: dict, + edges: list, + total_score: float, + max_depth: int, + root_uid: NodeId | None = None, + metadata_lookup: Callable[[int], dict] | None = None, + included_uids: set[NodeId] | None = None, + aggregate_by: str | None = None, +) -> list[dict]: + """Chain layout plus optional sibling aggregation for contribution-tree plots.""" + segments = build_chain_layout( + nodes, + edges, + total_score, + max_depth=max_depth, + root_uid=root_uid, + metadata_lookup=metadata_lookup, + included_uids=included_uids, + ) + if not segments: + return [] + if root_uid is None: + roots = [n for n in nodes.values() if getattr(n, "depth", None) == 0] + if len(roots) != 1: + return segments + root_uid = roots[0].unique_id + pcm = build_parent_child_map(nodes, edges) + return aggregate_plot_segments( + segments, aggregate_by, pcm, root_uid, total_score + ) + + # --------------------------------------------------------------------------- # Sunburst ring builder # --------------------------------------------------------------------------- @@ -546,7 +962,7 @@ def build_sunburst_rings( ``max_depth`` is the number of rings (tiers ``0 .. max_depth-1``). Each ring is a list of wedge dicts with ``unique_id``, ``label``, ``share``, - ``cumulative_score``, ``parent_unique_id``, ``is_other``. + ``cumulative_score``, ``parent_unique_id``. """ if not nodes or total_score == 0.0: return [] @@ -586,27 +1002,19 @@ def build_sunburst_rings( if parent_score == 0.0: continue - children_score_sum = sum(c.cumulative_score for c in children) + children.sort(key=lambda c: abs(c.cumulative_score), reverse=True) for child in children: - share = child.cumulative_score / parent_score if parent_score else 0.0 + share = ( + abs(child.cumulative_score) / abs(parent_score) + if parent_score + else 0.0 + ) ring.append({ "unique_id": child.unique_id, "label": getattr(child, "_label", str(child.unique_id)), "share": share, "cumulative_score": child.cumulative_score, "parent_unique_id": parent_id, - "is_other": False, - }) - - remainder = parent_score - children_score_sum - if abs(remainder) > abs(parent_score) * 1e-9: - ring.append({ - "unique_id": None, - "label": "other", - "share": remainder / parent_score, - "cumulative_score": remainder, - "parent_unique_id": parent_id, - "is_other": True, }) if ring: diff --git a/activity_browser/ui/widgets/plot.py b/activity_browser/ui/widgets/plot.py index cd461e688..5951a0723 100644 --- a/activity_browser/ui/widgets/plot.py +++ b/activity_browser/ui/widgets/plot.py @@ -249,6 +249,7 @@ def __init__(self, parent=None): self.ax = self.figure.add_subplot(111) self.plot_name = "Figure" self._hover_cid = None + self._click_cid = None self._tooltip_y: list[str] = [] self._tooltip_x: list[str] = [] self._tooltip_legend: list[str] = [] @@ -700,6 +701,24 @@ def _truncated_label_tooltip(self, event) -> str | None: return full_title return None + def clear_click_handler(self) -> None: + if self._click_cid is not None: + self.canvas.mpl_disconnect(self._click_cid) + self._click_cid = None + + def set_click_handler(self, on_click) -> None: + """Wire left-click on the canvas (``on_click(event)``).""" + self.clear_click_handler() + if on_click is None: + return + + def on_press(event): + if event.button != 1: + return + on_click(event) + + self._click_cid = self.canvas.mpl_connect("button_press_event", on_press) + def set_motion_tooltip( self, on_hover=None, @@ -842,6 +861,7 @@ def finish_plot( self, *, on_hover=None, + on_click=None, tooltip_y: list[str] | None = None, tooltip_x: list[str] | None = None, tooltip_legend: list[str] | None = None, @@ -855,6 +875,7 @@ def finish_plot( self.set_motion_tooltip( on_hover, y=tooltip_y, x=tooltip_x, legend=tooltip_legend ) + self.set_click_handler(on_click) self._schedule_figure_sync() def _save_figure(self, extension: str, file_filter: str) -> None: diff --git a/tests/test_contribution_tree.py b/tests/test_contribution_tree.py index 86497926e..013d8c707 100644 --- a/tests/test_contribution_tree.py +++ b/tests/test_contribution_tree.py @@ -12,14 +12,20 @@ import pytest from activity_browser.bwutils.contribution_tree import ( + aggregate_plot_segments, + build_chain_layout, build_parent_child_map, build_sunburst_rings, coverage_of_uids, cumulative_percent, + direct_impact_rgba, + direct_impact_intensity, direct_impact_coverage, direct_percent, flatten_to_dataframe, next_expand_candidates, + plot_click_target_uid, + is_terminal_node, path_display_set, plan_cumulative_expand, tree_stats, @@ -147,8 +153,7 @@ def test_sunburst_rings_tier1_shares(): rings = build_sunburst_rings(nodes, edges, 10.0, max_depth=1) assert len(rings) == 1 ring = rings[0] - real_wedges = [w for w in ring if not w["is_other"]] - shares = {w["unique_id"]: w["share"] for w in real_wedges} + shares = {w["unique_id"]: w["share"] for w in ring} assert shares[1] == pytest.approx(0.6) assert shares[2] == pytest.approx(0.4) @@ -163,14 +168,13 @@ def test_sunburst_rings_sum_le_one_per_parent(): assert total_share <= 1.0 + 1e-9 -def test_sunburst_rings_other_wedge_present_when_children_dont_sum(): - """Children C(3) + D(2) = 5; parent A(6) → other = 1/6.""" +def test_sunburst_rings_no_other_wedges(): + """Partial child lists no longer produce synthetic 'other' wedges.""" nodes, edges = _simple_tree() rings = build_sunburst_rings(nodes, edges, 10.0, max_depth=2) - ring2 = rings[1] - others = [w for w in ring2 if w["is_other"] and w["parent_unique_id"] == 1] - assert len(others) == 1 - assert others[0]["share"] == pytest.approx(1 / 6, rel=1e-6) + for ring in rings: + assert all(w.get("label") != "other" for w in ring) + assert all(w["unique_id"] is not None for w in ring) def test_sunburst_rings_max_depth_respected(): @@ -185,8 +189,7 @@ def test_sunburst_rings_empty_on_zero_total(): assert rings == [] -def test_sunburst_rings_no_other_when_children_match_parent(): - """When children sum exactly to parent, no 'other' wedge for that parent.""" +def test_sunburst_rings_exact_children_only(): nodes = { -1: _node(-1, 0, 10.0, 0.0), 1: _node(1, 1, 6.0, 1.0), @@ -194,8 +197,204 @@ def test_sunburst_rings_no_other_when_children_match_parent(): } edges = [_edge(-1, 1), _edge(-1, 2)] rings = build_sunburst_rings(nodes, edges, 10.0, max_depth=1) - others = [w for w in rings[0] if w["is_other"]] - assert others == [] + assert len(rings[0]) == 2 + + +# --------------------------------------------------------------------------- +# build_chain_layout +# --------------------------------------------------------------------------- + +def _rf_supplier_tree(): + """Virtual root → RF (uid 0) → suppliers; parent uid 0 must not be falsy.""" + nodes = { + -1: _node(-1, 0, 10.0, 0.0), + 0: _node(0, 1, 10.0, 3.0, activity_id=100), + 1: _node(1, 2, 6.0, 1.0, activity_id=101), + 2: _node(2, 2, 3.0, 1.0, activity_id=102), + } + edges = [_edge(-1, 0), _edge(0, 1), _edge(0, 2)] + return nodes, edges + + +def test_chain_layout_tier0_only_reference_flow(): + nodes, edges = _rf_supplier_tree() + segs = build_chain_layout(nodes, edges, 10.0, max_depth=2, root_uid=-1) + tier0 = [s for s in segs if s["tier"] == 0] + tier1 = [s for s in segs if s["tier"] == 1] + assert len(tier0) == 1 + assert tier0[0]["unique_id"] == 0 + assert {s["unique_id"] for s in tier1} == {1, 2} + + +def test_chain_layout_supply_chain_x_alignment(): + nodes, edges = _rf_supplier_tree() + segs = build_chain_layout(nodes, edges, 10.0, max_depth=2, root_uid=-1) + rf = next(s for s in segs if s["unique_id"] == 0) + children = [s for s in segs if s["tier"] == 1] + assert rf["x0"] == pytest.approx(0.0) + assert rf["x1"] == pytest.approx(1.0) + assert children[0]["x0"] == pytest.approx(0.0) + assert children[0]["x1"] == pytest.approx(0.6) + assert children[1]["x0"] == pytest.approx(0.6) + assert children[1]["x1"] == pytest.approx(0.9) + + +def test_chain_layout_respects_included_uids(): + nodes, edges = _rf_supplier_tree() + segs = build_chain_layout( + nodes, + edges, + 10.0, + max_depth=2, + root_uid=-1, + included_uids={0, 1}, + ) + assert {s["unique_id"] for s in segs} == {0, 1} + rf = next(s for s in segs if s["unique_id"] == 0) + child = next(s for s in segs if s["unique_id"] == 1) + assert rf["x0"] == pytest.approx(0.0) + assert rf["x1"] == pytest.approx(1.0) + assert child["x0"] == pytest.approx(0.0) + # RF direct is 30% — sole visible child reflows within 70% upstream band. + assert child["x1"] == pytest.approx(0.7) + + +def test_chain_layout_children_exclude_parent_direct(): + nodes, edges = _rf_supplier_tree() + segs = build_chain_layout( + nodes, + edges, + 10.0, + max_depth=2, + root_uid=-1, + included_uids={0, 1, 2}, + ) + rf = next(s for s in segs if s["unique_id"] == 0) + children = sorted( + (s for s in segs if s["tier"] == 1), + key=lambda s: s["x0"], + ) + assert rf["direct_pct"] == pytest.approx(30.0) + assert children[-1]["x1"] == pytest.approx(0.7) + + +def test_chain_layout_negative_impact_has_positive_span(): + """Credits (negative cumulative) occupy layout space by magnitude; keep sign for colour.""" + nodes = { + -1: _node(-1, 0, 10.0, 0.0), + 0: _node(0, 1, 10.0, 2.0, activity_id=100), + 1: _node(1, 2, 8.0, 1.0, activity_id=101), + 2: _node(2, 2, -2.0, -2.0, activity_id=102), + } + edges = [_edge(-1, 0), _edge(0, 1), _edge(0, 2)] + segs = build_chain_layout(nodes, edges, 10.0, max_depth=2, root_uid=-1) + credit = next(s for s in segs if s["unique_id"] == 2) + burden = next(s for s in segs if s["unique_id"] == 1) + assert credit["x1"] > credit["x0"] + assert credit["x1"] - credit["x0"] == pytest.approx(0.2) + assert burden["x1"] - burden["x0"] == pytest.approx(0.8) + assert credit["cumulative_score"] == pytest.approx(-2.0) + assert credit["direct_pct"] == pytest.approx(-20.0) + r, g, b, a = direct_impact_rgba(credit["direct_pct"], 20.0) + assert g > r # green credit tint + + +def test_chain_layout_credit_when_parent_direct_exceeds_cumulative(): + """Visible-tree reflow: |direct| > |cumulative| must not collapse the credit band.""" + nodes = { + -1: _node(-1, 0, 10.0, 0.0), + 0: _node(0, 1, 10.0, 16.667, activity_id=100), + 1: _node(1, 2, -6.667, -6.667, activity_id=101), + } + edges = [_edge(-1, 0), _edge(0, 1)] + segs = build_chain_layout( + nodes, + edges, + 10.0, + max_depth=2, + root_uid=-1, + included_uids={0, 1}, + ) + credit = next(s for s in segs if s["unique_id"] == 1) + assert credit["x1"] - credit["x0"] == pytest.approx(0.6667, abs=1e-3) + assert credit["direct_pct"] == pytest.approx(-66.67, abs=1e-2) + + +def test_plot_click_target_uid_uses_toggle_uid(): + assert plot_click_target_uid({"unique_id": 5, "toggle_uid": 2}) == 2 + assert plot_click_target_uid({"unique_id": 5}) == 5 + + +def test_is_terminal_node(): + nodes, edges = _rf_supplier_tree() + visited = {0, 1, 2} + assert is_terminal_node(nodes, edges, visited, 1) is True + assert is_terminal_node(nodes, edges, visited, 0) is False + assert is_terminal_node(nodes, edges, {0}, 1) is False + + +def test_aggregate_plot_segments_merges_siblings_by_location(): + nodes, edges = _rf_supplier_tree() + pcm = build_parent_child_map(nodes, edges) + segments = build_chain_layout(nodes, edges, 10.0, max_depth=2, root_uid=-1) + for seg in segments: + if seg["tier"] == 1: + seg["location"] = "CH" + seg["product"] = f"product-{seg['unique_id']}" + merged = aggregate_plot_segments(segments, "location", pcm, -1, 10.0) + tier1 = [s for s in merged if s["tier"] == 1] + assert len(tier1) == 1 + assert tier1[0]["is_aggregate"] is True + assert tier1[0]["toggle_uid"] == 0 + assert set(tier1[0]["constituent_uids"]) == {1, 2} + assert tier1[0]["cumulative_score"] == pytest.approx(9.0) + assert tier1[0]["x0"] == pytest.approx(0.0) + assert tier1[0]["x1"] == pytest.approx(0.7) + + +def test_aggregate_plot_segments_unknown_bucket(): + nodes, edges = _rf_supplier_tree() + pcm = build_parent_child_map(nodes, edges) + segments = build_chain_layout(nodes, edges, 10.0, max_depth=2, root_uid=-1) + for seg in segments: + if seg["unique_id"] == 1: + seg["location"] = "" + merged = aggregate_plot_segments(segments, "location", pcm, -1, 10.0) + keys = { + s.get("aggregate_key") + for s in merged + if s["tier"] == 1 and s.get("is_aggregate") + } + assert "(unknown)" in keys or any( + s["unique_id"] == 1 and s.get("aggregate_key") == "(unknown)" + for s in merged + if s["tier"] == 1 + ) + + +def test_aggregate_plot_segments_none_preserves_segments(): + nodes, edges = _rf_supplier_tree() + pcm = build_parent_child_map(nodes, edges) + segments = build_chain_layout(nodes, edges, 10.0, max_depth=2, root_uid=-1) + out = aggregate_plot_segments(segments, None, pcm, -1, 10.0) + assert len(out) == len(segments) + assert all(not s.get("is_aggregate") for s in out) + assert all(s["toggle_uid"] == s["unique_id"] for s in out) + + +def test_direct_impact_rgba_positive(): + r, g, b, a = direct_impact_rgba(50.0, 100.0) + assert r == pytest.approx(70 / 255) + assert a > 0.5 + + +def test_direct_impact_intensity_log_scale(): + lo = direct_impact_intensity(1.0, 100.0) + mid = direct_impact_intensity(10.0, 100.0) + hi = direct_impact_intensity(100.0, 100.0) + assert lo == pytest.approx(0.0) + assert hi == pytest.approx(1.0) + assert mid == pytest.approx(0.5) # --------------------------------------------------------------------------- diff --git a/tests/test_contribution_tree_plot.py b/tests/test_contribution_tree_plot.py new file mode 100644 index 000000000..d65277422 --- /dev/null +++ b/tests/test_contribution_tree_plot.py @@ -0,0 +1,70 @@ +"""Tests for Contribution Tree plot label helpers (no Qt).""" + +import numpy as np + +from activity_browser.app.pages.lca_results.contribution_tree_plot import ( + ContributionTreePlot, +) + + +def test_fit_label_shorten_at_word_boundary(): + text = "aluminium, primary, ingot" + out = ContributionTreePlot._fit_label(text, max_chars=12, max_lines=1) + assert out.endswith("…") + assert not out.endswith(", c…") + assert out.startswith("aluminium,") + + +def test_fit_label_unchanged_when_short(): + text = "electricity" + assert ContributionTreePlot._fit_label(text, max_chars=20) == text + + +def test_fit_label_wraps_to_two_lines(): + text = "electricity, medium voltage, aluminium industry" + out = ContributionTreePlot._fit_label(text, max_chars=18, max_lines=2) + lines = out.splitlines() + assert len(lines) <= 2 + assert lines[0].startswith("electricity,") + assert "medium" not in lines[0] or len(lines[0]) <= 20 + + +def test_fit_label_empty(): + assert ContributionTreePlot._fit_label("", max_chars=10) == "" + + +def test_label_worth_showing(): + assert ContributionTreePlot._label_worth_showing("aluminium, …") is True + assert ContributionTreePlot._label_worth_showing("…") is False + assert ContributionTreePlot._label_worth_showing("...") is False + + +def test_sunburst_tangent_rotation_readable(): + assert ContributionTreePlot._sunburst_tangent_rotation(0) == 0 + assert ContributionTreePlot._sunburst_tangent_rotation(np.pi) == 0 + + +def test_sunburst_radial_rotation_readable(): + # East: horizontal outward + assert ContributionTreePlot._sunburst_radial_rotation(np.pi / 2) == 0 + # North: vertical outward + assert ContributionTreePlot._sunburst_radial_rotation(0) == 270 + + +def test_lines_for_row_height(): + assert ContributionTreePlot._lines_for_row_height(0.22, 6.0) == 2 + assert ContributionTreePlot._lines_for_row_height(0.08, 6.0) == 1 + assert ContributionTreePlot._lines_for_row_height(0.65, 5.0) == 6 + + +def test_fit_label_chars_breaks_mid_word(): + text = "aluminium, primary, ingot" + out = ContributionTreePlot._fit_label_chars(text, max_chars=8, max_lines=3) + lines = out.splitlines() + assert len(lines) == 3 + assert all(len(line) <= 8 for line in lines[:-1]) + + +def test_icicle_column_fontsize(): + assert ContributionTreePlot._icicle_column_fontsize(0.25, 6.0) == 6.0 + assert ContributionTreePlot._icicle_column_fontsize(0.1, 6.0) == 5.0 From 4ae2a49c03b7e8254d58fedbef7e543d03e87d61 Mon Sep 17 00:00:00 2001 From: bsteubing-3?$#LIHFgV z`##Xmwr6y#>1LN^j-7q+?JIlTx+lisJ-e~~_cZzOA}((a4^o$Kgz`;)Dba=2V)p4< zaz)P?{XCkd*B)Q!i$an;hxg7V?kop-&{ Z;c<^(1U zy5zzcYlZol&=4+#XXRU><*r*c)hmVj^+-&0HL5^7;w vvc@jsr|e5 z43sh!ZSsjiF;*n0!$=8jI=_0PsJEz=xEuJ&%x6D1gx7@Qa@Txca_^FeK4}s7U4p_~ z=93rMeesJAjz*p@Rh`Y}Jw~Tz64wjk$>We67)$d=u0>4K?msQ&){0(^`9q5pcU`5| z1&ENAsg8){3T^m3*Ka;&QZLpfYnU-LL@h7$mun|!);$FFjjXjc9~C0btG)MXBo(2L z39%*p`n<_WJ?26KVy%gwIE4TTX~!ejXB0C_m(me$#nOD)+G3$}+YD{>8 &KL|h+~+4ynbYR!`i@kj!5|A$p6&Wxw-bPgIcy#V11xs@>VDf(JfUM|y8&2p8b zoQOmT*oci~jkL1JL~5cYVi7?Ws_qUQ$dn?Q+!P*bFw@a}I{rEYv+@+fz*-!Zbf97y zmWB@GenOV`X|Z4-+57lPl5zm8@7Vgwk;oA8#%jdIA(5}H+JV&fY}a<5o3`oA0=)xM zP;uKQ%GZ60v5R4LId(b(dK|I-**uOh>|JqaI9Gygs4{x(jpzAQXZeEYb&VZLk+N&} zLFKn0QvG$yty(Ub;d%p?OSw}`_0;FFv>1t2 z>D>&vKgSH4$9L4*5ujK|{-k+&tF3+F9v^kzbvfz_P1NlMUhoUEL60~CwMlO)a2fV- zu$DiJcFkOD+$_=+55IUUlHUmw1S6gY2F;NsH^1S(Nd7DP<|P^g{>nbEeCONmAqYxm zLB2!#5p&-ml D5V>TOa`NP7na(|6y&-y10*^HB#d*&X4T g#0U(N`9~m4*^bHOs?vDy2 uIsOI_Kmb1)HDz5F|8PrmWlN0uNxU13lh^w0S0RsrK*3IVbrHPWHs^m=J8w?_ 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_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 From 3df690ce23d10f36a7ed0088dee624605c9d7f59 Mon Sep 17 00:00:00 2001 From: bsteubing Date: Tue, 11 Aug 2026 01:37:03 +0200 Subject: [PATCH 04/10] Adding proper import/export functionality for impact categories (LCIA methods) --- CONTEXT.md | 12 + activity_browser/app/actions/__init__.py | 14 +- ...tary_flow.py => elementary_flow_delete.py} | 0 ...entary_flow.py => elementary_flow_edit.py} | 2 +- ...mentary_flow.py => elementary_flow_new.py} | 0 .../database_import_from_ecoinvent.py | 2 +- .../method/importer/method_importer_bw2io.py | 59 --- .../app/actions/method/method_export_ab.py | 121 +++++ .../app/actions/method/method_export_bw2io.py | 131 +++++ .../app/actions/method/method_get_template.py | 65 +++ .../app/actions/method/method_import_ab.py | 478 ++++++++++++++++++ .../app/actions/method/method_import_bw2io.py | 456 +++++++++++++++++ ...coinvent.py => method_import_ecoinvent.py} | 26 +- activity_browser/app/dialogs/__init__.py | 8 + .../app/dialogs/thread_progress.py | 38 ++ activity_browser/app/menu_bar.py | 26 +- .../app/panes/impact_categories.py | 75 +++ .../bwutils/impact_categories/__init__.py | 71 +++ .../bwutils/impact_categories/ab_lcia_file.py | 372 ++++++++++++++ .../impact_categories/bw2io_lcia_file.py | 182 +++++++ .../bwutils/impact_categories/common.py | 140 +++++ .../impact_categories/ecoinvent_lcia.py | 154 ++++++ .../bwutils/impact_categories/templates.py | 86 ++++ .../bwutils/io/ecoinvent_lcia_importer.py | 183 ------- activity_browser/bwutils/uncertainty.py | 3 + activity_browser/templates/README.md | 12 +- .../impact-categories/ab-lcia.cfs.csv | 3 + .../impact-categories/ab-lcia.metadata.csv | 2 + .../templates/impact-categories/ab-lcia.xlsx | Bin 0 -> 6374 bytes .../impact-categories/bw2io-lcia.csv | 2 + .../impact-categories/bw2io-lcia.metadata.csv | 3 + .../impact-categories/bw2io-lcia.xlsx | Bin 0 -> 6236 bytes activity_browser/ui/core/threading.py | 20 +- .../ui/dialogs/progress_dialog.py | 73 ++- .../impact-category-interchange.md | 56 ++ docs/agents/impact-category-interchange.md | 13 + .../user-interface/panes/impact-categories.md | 5 +- tests/test_ab_lcia_interchange.py | 289 +++++++++++ tests/test_activity_edit_elementary_flow.py | 2 +- tests/test_activity_new_elementary_flow.py | 2 +- tests/test_impact_category_templates.py | 28 + tests/test_method_import_progress_dialog.py | 28 + 42 files changed, 2962 insertions(+), 280 deletions(-) rename activity_browser/app/actions/activity/{delete_elementary_flow.py => elementary_flow_delete.py} (100%) rename activity_browser/app/actions/activity/{edit_elementary_flow.py => elementary_flow_edit.py} (96%) rename activity_browser/app/actions/activity/{new_elementary_flow.py => elementary_flow_new.py} (100%) delete mode 100644 activity_browser/app/actions/method/importer/method_importer_bw2io.py create mode 100644 activity_browser/app/actions/method/method_export_ab.py create mode 100644 activity_browser/app/actions/method/method_export_bw2io.py create mode 100644 activity_browser/app/actions/method/method_get_template.py create mode 100644 activity_browser/app/actions/method/method_import_ab.py create mode 100644 activity_browser/app/actions/method/method_import_bw2io.py rename activity_browser/app/actions/method/{importer/method_importer_ecoinvent.py => method_import_ecoinvent.py} (84%) create mode 100644 activity_browser/app/dialogs/thread_progress.py create mode 100644 activity_browser/bwutils/impact_categories/__init__.py create mode 100644 activity_browser/bwutils/impact_categories/ab_lcia_file.py create mode 100644 activity_browser/bwutils/impact_categories/bw2io_lcia_file.py create mode 100644 activity_browser/bwutils/impact_categories/common.py create mode 100644 activity_browser/bwutils/impact_categories/ecoinvent_lcia.py create mode 100644 activity_browser/bwutils/impact_categories/templates.py delete mode 100644 activity_browser/bwutils/io/ecoinvent_lcia_importer.py create mode 100644 activity_browser/templates/impact-categories/ab-lcia.cfs.csv create mode 100644 activity_browser/templates/impact-categories/ab-lcia.metadata.csv create mode 100644 activity_browser/templates/impact-categories/ab-lcia.xlsx create mode 100644 activity_browser/templates/impact-categories/bw2io-lcia.csv create mode 100644 activity_browser/templates/impact-categories/bw2io-lcia.metadata.csv create mode 100644 activity_browser/templates/impact-categories/bw2io-lcia.xlsx create mode 100644 docs/advanced-topics/impact-category-interchange.md create mode 100644 docs/agents/impact-category-interchange.md create mode 100644 tests/test_ab_lcia_interchange.py create mode 100644 tests/test_impact_category_templates.py create mode 100644 tests/test_method_import_progress_dialog.py diff --git a/CONTEXT.md b/CONTEXT.md index d31e5acc0..d175f07f7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -57,6 +57,16 @@ A named set of functional unit(s) and LCIA method(s) used to run LCA / multi-LCA A Life Cycle Impact Assessment method (characterization factors for elementary flows). In Brightway, methods are keyed tuples; AB exposes them in impact-category UI. +### AB impact-category file (AB LCIA format) + +Activity Browser’s multi–impact-category interchange for import/export: characterization factors plus per–impact-category unit and description. Method keys and elementary-flow identities use `::` (variable-length Brightway tuples / categories). Excel uses sheets `CFs` and `Impact categories`; CSV uses a sibling pair `*.cfs.csv` + `*.metadata.csv`. Distinct from ecoinvent’s LCIA implementation workbook (fixed three-part names; separate name/compartment/subcompartment columns) and from the **bw2io impact-category file**. +_Avoid_: Indicators sheet (when meaning AB’s impact-category metadata table), AB ecoinvent format + +### bw2io impact-category file (bw2io LCIA format) + +bw2io’s Excel/CSV LCIA CF template: **one impact category per CF file/sheet** (`name`, `categories` with `::`, `amount`, optional uncertainty). AB may add a `metadata` sheet (xlsx) or `metadata.csv` sidecar for method/unit/description/`filename`; stock bw2io only needs the CF table. +_Avoid_: one-shot, bw2io native (as a product name), AB impact-category file + ### Characterization factor (CF) A factor that converts an elementary flow amount into an impact-category score for a given method. @@ -120,4 +130,6 @@ Extensibility mechanism for third-party AB features. **Architecture TBD** — do | “table of processes” | database / activity | | “flow” without kind | intermediate (technosphere) or elementary (biosphere) flow; exchanges is another synonym | | “impact method” only | LCIA method / impact category (as used in UI) | +| “Indicators” (AB LCIA metadata sheet/file) | Impact categories (Excel sheet) / `.metadata.csv` (AB CSV sidecar) | +| “one-shot” / “bw2io native” (LCIA file) | bw2io impact-category file | | “global app settings file” ad hoc | `app.settings` | diff --git a/activity_browser/app/actions/__init__.py b/activity_browser/app/actions/__init__.py index b5808ccf3..0f0e735a9 100644 --- a/activity_browser/app/actions/__init__.py +++ b/activity_browser/app/actions/__init__.py @@ -6,9 +6,9 @@ from .activity.activity_modify import ActivityModify from .activity.activity_new_process import ActivityNewProcess from .activity.activity_new_product import ActivityNewProduct -from .activity.new_elementary_flow import NewElementaryFlow -from .activity.edit_elementary_flow import EditElementaryFlow -from .activity.delete_elementary_flow import DeleteElementaryFlow +from .activity.elementary_flow_new import NewElementaryFlow +from .activity.elementary_flow_edit import EditElementaryFlow +from .activity.elementary_flow_delete import DeleteElementaryFlow from .activity.activity_open import ActivityOpen from .activity.activity_relink import ActivityRelink from .activity.activity_sdf_to_clipboard import ActivitySDFToClipboard @@ -59,8 +59,12 @@ from .method.method_meta_modify import MethodMetaModify from .method.method_new import MethodNew -from .method.importer.method_importer_ecoinvent import MethodImporterEcoinvent -from .method.importer.method_importer_bw2io import MethodImporterBW2IO +from .method.method_import_ecoinvent import MethodImportEcoinvent +from .method.method_import_ab import MethodImportAB +from .method.method_import_bw2io import MethodImportBW2IO +from .method.method_export_ab import MethodExportAB +from .method.method_export_bw2io import MethodExportBW2IO +from .method.method_get_template import MethodGetTemplate from .method.cf_uncertainty_modify import CFUncertaintyModify from .method.cf_amount_modify import CFAmountModify diff --git a/activity_browser/app/actions/activity/delete_elementary_flow.py b/activity_browser/app/actions/activity/elementary_flow_delete.py similarity index 100% rename from activity_browser/app/actions/activity/delete_elementary_flow.py rename to activity_browser/app/actions/activity/elementary_flow_delete.py diff --git a/activity_browser/app/actions/activity/edit_elementary_flow.py b/activity_browser/app/actions/activity/elementary_flow_edit.py similarity index 96% rename from activity_browser/app/actions/activity/edit_elementary_flow.py rename to activity_browser/app/actions/activity/elementary_flow_edit.py index db5b60a88..c7ad8e352 100644 --- a/activity_browser/app/actions/activity/edit_elementary_flow.py +++ b/activity_browser/app/actions/activity/elementary_flow_edit.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.app.actions.activity.new_elementary_flow import ElementaryFlowDialog +from activity_browser.app.actions.activity.elementary_flow_new import ElementaryFlowDialog from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils.commontasks import ( get_writable_databases, diff --git a/activity_browser/app/actions/activity/new_elementary_flow.py b/activity_browser/app/actions/activity/elementary_flow_new.py similarity index 100% rename from activity_browser/app/actions/activity/new_elementary_flow.py rename to activity_browser/app/actions/activity/elementary_flow_new.py diff --git a/activity_browser/app/actions/database/database_import_from_ecoinvent.py b/activity_browser/app/actions/database/database_import_from_ecoinvent.py index 0e45431e8..66fe3c62d 100644 --- a/activity_browser/app/actions/database/database_import_from_ecoinvent.py +++ b/activity_browser/app/actions/database/database_import_from_ecoinvent.py @@ -16,7 +16,7 @@ from activity_browser.ui import widgets, icons from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.bwutils.io.ecoinvent_importer import Ecoinvent7zImporter -from activity_browser.bwutils.io.ecoinvent_lcia_importer import EcoinventLCIAImporter +from activity_browser.bwutils.impact_categories import EcoinventLCIAImporter from activity_browser.mod.bw2io.migrations import ab_create_core_migrations from activity_browser.ui.core import threading diff --git a/activity_browser/app/actions/method/importer/method_importer_bw2io.py b/activity_browser/app/actions/method/importer/method_importer_bw2io.py deleted file mode 100644 index 4d6dfc8b0..000000000 --- a/activity_browser/app/actions/method/importer/method_importer_bw2io.py +++ /dev/null @@ -1,59 +0,0 @@ -import os.path -from loguru import logger - -from qtpy.QtCore import Signal, SignalInstance - -from activity_browser import app -from activity_browser.app.actions.base import exception_dialogs -from activity_browser.ui import icons, widgets -from activity_browser.bwutils.io.ecoinvent_lcia_importer import EcoinventLCIAImporter -from activity_browser.ui.core import threading - -from .method_importer_ecoinvent import ExtractExcelThread, MethodImporterEcoinvent - - - - -class MethodImporterBW2IO(MethodImporterEcoinvent): - """ABAction to import ecoinvent methods shipped with BW2IO""" - - icon = icons.qicons.import_db - text = "Import from bw2io..." - tool_tip = "Import methods that come shipped with BW2IO" - - @classmethod - @exception_dialogs - def run(cls): - # initialize the import thread, setting needed attributes - extract_thread = ExtractMethodsThread(app.application) - extract_thread.loaded.connect(cls.write_database) - - # show progress dialog for importing the excel - progress_dialog = widgets.ABProgressDialog.get_connected_dialog("Importing Database") - extract_thread.finished.connect(progress_dialog.deleteLater) - - extract_thread.start() - - -class ExtractMethodsThread(threading.ABThread): - loaded: SignalInstance = Signal(EcoinventLCIAImporter) - - def run_safely(self): - import zipfile - import json - from bw2io.data import dirpath - - fp = os.path.join(dirpath, "lcia", "lcia_39_ecoinvent.zip") - - with zipfile.ZipFile(fp, mode="r") as archive: - data = json.load(archive.open("data.json")) - - for method in data: - method['name'] = tuple(method['name']) - for obj in method['exchanges']: - del obj['input'] - - ei = EcoinventLCIAImporter("lcia_39_ecoinvent.zip") - ei.data = data - self.loaded.emit(ei) - diff --git a/activity_browser/app/actions/method/method_export_ab.py b/activity_browser/app/actions/method/method_export_ab.py new file mode 100644 index 000000000..65c76df05 --- /dev/null +++ b/activity_browser/app/actions/method/method_export_ab.py @@ -0,0 +1,121 @@ +"""Export impact categories to an AB LCIA file (.xlsx/.csv).""" +from __future__ import annotations + +from typing import List, Optional, Sequence + +from loguru import logger +from qtpy import QtWidgets +from qtpy.QtCore import Signal, SignalInstance + +from activity_browser import app +from activity_browser.app import application +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.app.panes.impact_categories import resolve_methods_for_export +from activity_browser.bwutils.impact_categories import ( + CancelledError, + export_methods_ab_csv_pair, + export_methods_ab_xlsx, +) +from activity_browser.ui.core import threading +from activity_browser.app.dialogs import run_thread_with_progress + + +class MethodExportAB(ABAction): + """Export selected (or all) impact categories to an AB LCIA Excel workbook.""" + + icon = application.style().standardIcon(QtWidgets.QStyle.SP_DialogSaveButton) + text = "To AB LCIA file (.xlsx/.csv)…" + tool_tip = "Export impact categories to Activity Browser spreadsheet format" + + @classmethod + @exception_dialogs + def run(cls, method_names: Optional[List[tuple]] = None): + method_names = resolve_methods_for_export(method_names) + if not method_names: + return + + path, selected_filter = QtWidgets.QFileDialog.getSaveFileName( + parent=app.main_window, + caption="Export impact categories (AB impact-category file)", + directory="ab-impact-categories.xlsx", + filter="Excel spreadsheet (*.xlsx);;CSV pair (*.cfs.csv);; All files (*.*)", + ) + if not path: + return + + as_csv = "CSV" in selected_filter or path.lower().endswith(".cfs.csv") + if as_csv and path.lower().endswith(".xlsx"): + path = path[:-5] + elif not as_csv and not path.lower().endswith(".xlsx"): + path = path + ".xlsx" + + export_ab_with_progress(method_names, path, as_csv=as_csv) + + +class ExportABThread(threading.ABThread): + done: SignalInstance = Signal(str) + failed: SignalInstance = Signal(str) + + method_names: list + path: str + as_csv: bool + + def run_safely(self): + try: + if self.ab_cancel_requested(): + return + cancel = lambda: self.ab_cancel_requested() + if self.as_csv: + cfs_path, ic_path = export_methods_ab_csv_pair( + self.method_names, self.path, cancel_check=cancel + ) + message = f"{cfs_path}\n{ic_path}" + else: + export_methods_ab_xlsx( + self.method_names, self.path, cancel_check=cancel + ) + message = self.path + except CancelledError: + self.request_ab_cancel() + return + except Exception as exc: + self.failed.emit(str(exc)) + return + if self.ab_cancel_requested(): + return + self.done.emit(message) + + +def export_ab_with_progress( + method_names: Sequence[tuple], + path: str, + *, + as_csv: bool, +) -> None: + thread = ExportABThread(app.application) + thread.method_names = list(method_names) + thread.path = path + thread.as_csv = as_csv + + def done(message: str): + logger.info( + f"Exported {len(method_names)} impact categories to {message.replace(chr(10), ' and ')}" + ) + QtWidgets.QMessageBox.information( + app.main_window, + "Export complete", + f"Exported {len(method_names)} impact categories to:\n{message}", + ) + + def failed(message: str): + QtWidgets.QMessageBox.warning(app.main_window, "Export impact categories", message) + + thread.done.connect(done) + thread.failed.connect(failed) + run_thread_with_progress( + "Exporting impact categories", + thread, + on_cancelled=lambda: QtWidgets.QMessageBox.information( + app.main_window, "Export cancelled", "Export cancelled." + ), + ) diff --git a/activity_browser/app/actions/method/method_export_bw2io.py b/activity_browser/app/actions/method/method_export_bw2io.py new file mode 100644 index 000000000..7470f23b9 --- /dev/null +++ b/activity_browser/app/actions/method/method_export_bw2io.py @@ -0,0 +1,131 @@ +"""Export impact categories as bw2io LCIA files.""" +from __future__ import annotations + +from pathlib import Path +from typing import Sequence + +from qtpy import QtWidgets +from qtpy.QtCore import Signal, SignalInstance + +from activity_browser import app +from activity_browser.app import application +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.app.panes.impact_categories import resolve_methods_for_export +from activity_browser.bwutils.impact_categories import ( + CancelledError, + method_name_to_filename_stem, + raise_if_cancelled, +) +from activity_browser.bwutils.impact_categories.bw2io_lcia_file import ( + export_method_bw2io_xlsx, + export_methods_bw2io_csv_batch, +) +from activity_browser.ui.core import threading +from activity_browser.app.dialogs import run_thread_with_progress + + +class MethodExportBW2IO(ABAction): + icon = application.style().standardIcon(QtWidgets.QStyle.SP_DialogSaveButton) + text = "To bw2io LCIA file (.xlsx/.csv)…" + tool_tip = "Export impact categories as bw2io LCIA files" + + @classmethod + @exception_dialogs + def run(cls, method_names: list[tuple] | None = None): + method_names = resolve_methods_for_export(method_names) + if not method_names: + return + + fmt, ok = QtWidgets.QInputDialog.getItem( + app.main_window, + "bw2io export format", + "Format:", + ["Excel (.xlsx, one file per impact category)", "CSV (folder + metadata.csv)"], + 0, + False, + ) + if not ok: + return + + directory = QtWidgets.QFileDialog.getExistingDirectory( + app.main_window, + "Select folder for bw2io Excel files" + if fmt.startswith("Excel") + else "Select folder for bw2io CSV files", + ) + if not directory: + return + + export_bw2io_with_progress( + method_names, directory, as_excel=fmt.startswith("Excel") + ) + + +class ExportBW2IOThread(threading.ABThread): + done: SignalInstance = Signal(str) + failed: SignalInstance = Signal(str) + + method_names: list + directory: str + as_excel: bool + + def run_safely(self): + try: + directory = Path(self.directory) + cancel = lambda: self.ab_cancel_requested() + if self.as_excel: + import tqdm + + for name in tqdm.tqdm( + self.method_names, + desc="Exporting bw2io Excel", + total=len(self.method_names), + ): + raise_if_cancelled(cancel) + path = directory / f"{method_name_to_filename_stem(name)}.xlsx" + export_method_bw2io_xlsx(name, path) + message = ( + f"Wrote {len(self.method_names)} Excel file(s) to:\n{directory}" + ) + else: + written = export_methods_bw2io_csv_batch( + self.method_names, directory, cancel_check=cancel + ) + message = f"Wrote {len(written)} file(s) to:\n{directory}" + except CancelledError: + self.request_ab_cancel() + return + except Exception as exc: + self.failed.emit(str(exc)) + return + if self.ab_cancel_requested(): + return + self.done.emit(message) + + +def export_bw2io_with_progress( + method_names: Sequence[tuple], + directory: str, + *, + as_excel: bool, +) -> None: + thread = ExportBW2IOThread(app.application) + thread.method_names = list(method_names) + thread.directory = directory + thread.as_excel = as_excel + + def done(message: str): + QtWidgets.QMessageBox.information(app.main_window, "Export complete", message) + + def failed(message: str): + QtWidgets.QMessageBox.warning(app.main_window, "Export impact categories", message) + + thread.done.connect(done) + thread.failed.connect(failed) + run_thread_with_progress( + "Exporting impact categories", + thread, + on_cancelled=lambda: QtWidgets.QMessageBox.information( + app.main_window, "Export cancelled", "Export cancelled." + ), + ) diff --git a/activity_browser/app/actions/method/method_get_template.py b/activity_browser/app/actions/method/method_get_template.py new file mode 100644 index 000000000..1356bd3c8 --- /dev/null +++ b/activity_browser/app/actions/method/method_get_template.py @@ -0,0 +1,65 @@ +"""Copy impact-category spreadsheet templates for the user.""" +from pathlib import Path + +from qtpy import QtWidgets + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.impact_categories.templates import ( + TEMPLATE_LABELS, + copy_impact_category_template, +) +from activity_browser.ui.icons import qicons + + +class MethodGetTemplate(ABAction): + icon = qicons.import_db + text = "Get template…" + tool_tip = "Save an impact-category import/export starter template" + + @classmethod + @exception_dialogs + def run(cls): + kind, ok = QtWidgets.QInputDialog.getItem( + app.main_window, + "Get impact-category template", + "Choose a format:\n\n" + "AB impact-category file = multi–impact-category (recommended default).\n" + "bw2io impact-category file = one impact category per CF file.", + list(TEMPLATE_LABELS.values()), + 0, + False, + ) + if not ok or not kind: + return + # map label back to key + label_to_kind = {v: k for k, v in TEMPLATE_LABELS.items()} + key = label_to_kind[kind] + + if key.endswith("csv"): + path = QtWidgets.QFileDialog.getExistingDirectory( + app.main_window, + "Select folder for CSV template files", + ) + if not path: + return + stem = "ab-lcia" if key.startswith("ab") else "bw2io-lcia" + written = copy_impact_category_template(key, Path(path) / stem) + else: + suggested = "ab-lcia.xlsx" if key.startswith("ab") else "bw2io-lcia.xlsx" + path, _ = QtWidgets.QFileDialog.getSaveFileName( + app.main_window, + "Save impact-category template", + suggested, + "Excel spreadsheet (*.xlsx);; All files (*.*)", + ) + if not path: + return + written = copy_impact_category_template(key, Path(path)) + + names = "\n".join(str(p) for p in written) + QtWidgets.QMessageBox.information( + app.main_window, + "Template saved", + f"Wrote template file(s):\n{names}", + ) diff --git a/activity_browser/app/actions/method/method_import_ab.py b/activity_browser/app/actions/method/method_import_ab.py new file mode 100644 index 000000000..477150b99 --- /dev/null +++ b/activity_browser/app/actions/method/method_import_ab.py @@ -0,0 +1,478 @@ +"""Import impact categories from an AB LCIA file (.xlsx/.csv).""" +from __future__ import annotations + +import csv +from enum import Enum +from typing import Callable, Optional + +from loguru import logger +from qtpy import QtCore, QtWidgets +from qtpy.QtCore import Signal, SignalInstance + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.bwutils.impact_categories import ( + ABLCIAImporter, + CancelledError, + ConflictMode, + ab_csv_sibling_path, + apply_name_conflicts, + drop_unlinked_exchanges, + exchange_link_counts, + join_tuple_path, + load_ab_csv_pair, + load_ab_xlsx, + split_tuple_path, + unlinked_exchanges, +) +from activity_browser.mod import bw2data as bd +from activity_browser.ui import widgets +from activity_browser.ui.core import threading +from activity_browser.app.dialogs import run_thread_with_progress +from activity_browser.ui.icons import qicons + + +class MethodImportAB(ABAction): + """Import impact categories from an AB LCIA Excel workbook or CSV pair.""" + + icon = qicons.import_db + text = "From AB LCIA file (.xlsx/.csv)…" + tool_tip = "Import impact categories from Activity Browser spreadsheet format" + + @classmethod + @exception_dialogs + def run(cls): + path, _ = QtWidgets.QFileDialog.getOpenFileName( + parent=app.main_window, + caption="Import impact categories (AB impact-category file)", + filter=( + "AB LCIA (*.xlsx *.cfs.csv *.metadata.csv);;" + "Excel spreadsheet (*.xlsx);;" + "AB CSV (*.cfs.csv *.metadata.csv);;" + "All files (*.*)" + ), + ) + if not path: + return + + other = None + if not path.lower().endswith(".xlsx"): + sibling = ab_csv_sibling_path(path) + if sibling is None or not sibling.is_file(): + other, _ = QtWidgets.QFileDialog.getOpenFileName( + parent=app.main_window, + caption="Select matching AB CSV sibling file", + filter="AB CSV (*.cfs.csv *.metadata.csv);; All files (*.*)", + ) + if not other: + return + + def after_load(data: list): + if not data: + QtWidgets.QMessageBox.warning( + app.main_window, + "Import impact categories", + "No impact categories found in the selected file.", + ) + return + + setup = MultiImportSetupDialog(data, parent=app.main_window) + if setup.exec_() != QtWidgets.QDialog.Accepted: + return + + finalize_lcia_import( + setup.prepared_data, + biosphere_name=setup.biosphere_name, + overwrite=setup.conflict_mode == ConflictMode.OVERWRITE, + ) + + load_ab_file_with_progress(path, other_path=other, on_loaded=after_load) + + +class UnlinkedDecision(str, Enum): + CANCEL = "cancel" + EXPORT = "export" + DROP = "drop" + CONTINUE = "continue" + + +def export_unmatched_cfs(unmatched: list[dict], parent=None) -> None: + path, _ = QtWidgets.QFileDialog.getSaveFileName( + parent or app.main_window, + "Export unmatched characterization factors", + "unmatched-cfs.csv", + "CSV (*.csv);; All files (*.*)", + ) + if not path: + return + if not path.lower().endswith(".csv"): + path = path + ".csv" + fieldnames = ["method", "name", "categories", "amount"] + with open(path, "w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + for row in unmatched: + writer.writerow( + { + "method": "::".join(map(str, row.get("method", ()))), + "name": row.get("name", ""), + "categories": "::".join(map(str, row.get("categories", ()))), + "amount": row.get("amount", ""), + } + ) + QtWidgets.QMessageBox.information( + parent or app.main_window, + "Unmatched CFs exported", + f"Wrote {len(unmatched)} rows to:\n{path}", + ) + + +def ask_unlinked_cfs(linked: int, unlinked: int, parent=None) -> UnlinkedDecision: + box = QtWidgets.QMessageBox(parent or app.main_window) + box.setWindowTitle("Characterization factor linking") + box.setIcon(QtWidgets.QMessageBox.Warning) + box.setText(f"Linked: {linked} | Unlinked: {unlinked}") + box.setInformativeText( + "Cancel import, export the unmatched list, or drop unlinked CFs and continue?" + ) + cancel = box.addButton("Cancel", QtWidgets.QMessageBox.RejectRole) + export_btn = box.addButton("Export unmatched…", QtWidgets.QMessageBox.ActionRole) + drop = box.addButton("Drop unlinked", QtWidgets.QMessageBox.DestructiveRole) + box.setDefaultButton(cancel) + box.exec_() + clicked = box.clickedButton() + if clicked is export_btn: + return UnlinkedDecision.EXPORT + if clicked is drop: + confirm = QtWidgets.QMessageBox.question( + parent or app.main_window, + "Drop unlinked CFs", + "Drop all unlinked characterization factors and write the rest?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + return ( + UnlinkedDecision.DROP + if confirm == QtWidgets.QMessageBox.Yes + else UnlinkedDecision.CANCEL + ) + return UnlinkedDecision.CANCEL + + +class MultiImportSetupDialog(QtWidgets.QDialog): + """Biosphere + conflict policy for multi–impact-category imports.""" + + biosphere_name: str + conflict_mode: ConflictMode + prepared_data: list + + def __init__(self, data: list[dict], parent=None): + super().__init__(parent) + self._data = data + self.setWindowTitle("Import impact categories") + + self.db_chooser = widgets.ABComboBox.get_database_combobox(self) + default_bio = bd.config.biosphere + idx = self.db_chooser.findText(default_bio) + if idx >= 0: + self.db_chooser.setCurrentIndex(idx) + + self.conflict_skip = QtWidgets.QRadioButton("Skip existing impact categories") + self.conflict_overwrite = QtWidgets.QRadioButton("Overwrite existing") + self.conflict_rename = QtWidgets.QRadioButton("Rename conflicts with prefix") + self.conflict_skip.setChecked(True) + self.prefix_edit = QtWidgets.QLineEdit() + self.prefix_edit.setPlaceholderText("Namespace prefix (e.g. Import 2026)") + self.prefix_edit.setEnabled(False) + self.conflict_rename.toggled.connect(self.prefix_edit.setEnabled) + + existing = set(bd.methods) + conflicts = [ds for ds in data if tuple(ds["name"]) in existing] + self._conflict_table: Optional[QtWidgets.QTableWidget] = None + if conflicts: + self._conflict_table = QtWidgets.QTableWidget(len(conflicts), 2) + self._conflict_table.setHorizontalHeaderLabels( + ["Existing name", "Import as (:: editable)"] + ) + self._conflict_table.horizontalHeader().setStretchLastSection(True) + for row, ds in enumerate(conflicts): + original = join_tuple_path(ds["name"]) + item0 = QtWidgets.QTableWidgetItem(original) + item0.setFlags(item0.flags() & ~QtCore.Qt.ItemIsEditable) + item0.setData(QtCore.Qt.UserRole, tuple(ds["name"])) + self._conflict_table.setItem(row, 0, item0) + self._conflict_table.setItem( + row, 1, QtWidgets.QTableWidgetItem(original) + ) + + info = QtWidgets.QLabel( + f"File contains {len(data)} impact categories" + + (f" ({len(conflicts)} name conflicts)." if conflicts else ".") + ) + info.setWordWrap(True) + + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(info) + layout.addWidget(QtWidgets.QLabel("Biosphere database:")) + layout.addWidget(self.db_chooser) + layout.addWidget(QtWidgets.QLabel("If names already exist:")) + layout.addWidget(self.conflict_skip) + layout.addWidget(self.conflict_overwrite) + layout.addWidget(self.conflict_rename) + layout.addWidget(self.prefix_edit) + if self._conflict_table is not None: + layout.addWidget( + QtWidgets.QLabel( + "Per-conflict rename (optional; overrides bulk policy for edited rows):" + ) + ) + layout.addWidget(self._conflict_table) + layout.addWidget(buttons) + + def _table_renames(self) -> dict[tuple, tuple]: + renames: dict[tuple, tuple] = {} + if self._conflict_table is None: + return renames + for row in range(self._conflict_table.rowCount()): + original = self._conflict_table.item(row, 0).data(QtCore.Qt.UserRole) + target_text = (self._conflict_table.item(row, 1).text() or "").strip() + target = split_tuple_path(target_text) + if target and target != original: + renames[tuple(original)] = target + return renames + + def accept(self): + if self.conflict_overwrite.isChecked(): + confirm = QtWidgets.QMessageBox.question( + self, + "Overwrite impact categories", + "Overwrite all conflicting impact categories?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if confirm != QtWidgets.QMessageBox.Yes: + return + mode = ConflictMode.OVERWRITE + prefix = None + elif self.conflict_rename.isChecked(): + mode = ConflictMode.RENAME_PREFIX + prefix = self.prefix_edit.text().strip() + if not prefix: + QtWidgets.QMessageBox.warning( + self, + "Rename conflicts", + "Please enter a namespace prefix.", + ) + return + else: + mode = ConflictMode.SKIP + prefix = None + + self.biosphere_name = self.db_chooser.currentText() + self.conflict_mode = mode + self.prepared_data = apply_name_conflicts( + self._data, + set(bd.methods), + mode=mode, + prefix=prefix, + renames=self._table_renames(), + ) + if not self.prepared_data: + QtWidgets.QMessageBox.information( + self, + "Import impact categories", + "Nothing left to import after applying the conflict policy.", + ) + return + super().accept() + + +def _cancel_check(thread: threading.ABThread) -> bool: + return thread.ab_cancel_requested() + + +def notify_import_cancelled(title: str = "Cancelled") -> None: + QtWidgets.QMessageBox.information( + app.main_window, + title, + "Operation cancelled. No impact categories were written to the project.", + ) + + +class LoadABFileThread(threading.ABThread): + loaded: SignalInstance = Signal(object) + failed: SignalInstance = Signal(str) + + path: str + other_path: Optional[str] = None + + def run_safely(self): + path = self.path + try: + if self.ab_cancel_requested(): + return + if path.lower().endswith(".xlsx"): + data = load_ab_xlsx(path) + else: + data = load_ab_csv_pair(path, other_path=self.other_path) + except (ValueError, FileNotFoundError) as exc: + self.failed.emit(str(exc)) + return + if self.ab_cancel_requested(): + return + self.loaded.emit(data) + + +class LinkLCIAThread(threading.ABThread): + linked: SignalInstance = Signal(object) + + data: list + biosphere_name: str + + def run_safely(self): + try: + importer = ABLCIAImporter(self.data, biosphere=self.biosphere_name) + importer.apply_strategies(cancel_check=lambda: _cancel_check(self)) + except CancelledError: + self.request_ab_cancel() + return + if self.ab_cancel_requested(): + return + self.linked.emit(importer) + + +class WriteLCIAThread(threading.ABThread): + written: SignalInstance = Signal(int) + failed: SignalInstance = Signal(str) + + importer: ABLCIAImporter + overwrite: bool + biosphere_name: str + + def run_safely(self): + try: + self.importer.write_methods( + overwrite=self.overwrite, + verbose=False, + cancel_check=lambda: _cancel_check(self), + ) + except CancelledError: + self.request_ab_cancel() + return + except ValueError as exc: + self.failed.emit(str(exc)) + return + if self.ab_cancel_requested(): + return + logger.info( + f"Imported {len(self.importer.data)} impact categories " + f"(biosphere={self.biosphere_name})" + ) + self.written.emit(len(self.importer.data)) + + +def load_ab_file_with_progress( + path: str, + *, + other_path: Optional[str] = None, + on_loaded: Callable[[list], None], +) -> None: + thread = LoadABFileThread(app.application) + thread.path = path + thread.other_path = other_path + + def _fail(message: str): + QtWidgets.QMessageBox.warning( + app.main_window, "Import impact categories", message + ) + + thread.failed.connect(_fail) + thread.loaded.connect(on_loaded) + run_thread_with_progress( + "Loading impact categories", + thread, + on_cancelled=lambda: notify_import_cancelled("Import cancelled"), + ) + + +def finalize_lcia_import( + data: list[dict], + *, + biosphere_name: str, + overwrite: bool, + parent=None, +) -> None: + """Apply strategies (with progress), gate on unlinked CFs, then write.""" + parent = parent or app.main_window + link_thread = LinkLCIAThread(app.application) + link_thread.data = data + link_thread.biosphere_name = biosphere_name + + def after_link(importer: ABLCIAImporter): + linked, unlinked = exchange_link_counts(importer.data) + unmatched = unlinked_exchanges(importer.data) + + if unlinked: + decision = ask_unlinked_cfs(linked, unlinked, parent=parent) + if decision == UnlinkedDecision.CANCEL: + return + if decision == UnlinkedDecision.EXPORT: + export_unmatched_cfs(unmatched, parent=parent) + return + if decision == UnlinkedDecision.DROP: + importer.data = drop_unlinked_exchanges(importer.data) + else: + QtWidgets.QMessageBox.information( + parent, + "Ready to import", + f"Linked: {linked} | Unlinked: 0\n\n" + f"Writing {len(importer.data)} impact categories.", + ) + + write_thread = WriteLCIAThread(app.application) + write_thread.importer = importer + write_thread.overwrite = overwrite + write_thread.biosphere_name = biosphere_name + + def after_write(count: int): + QtWidgets.QMessageBox.information( + parent, + "Import complete", + f"Imported {count} impact categories.", + ) + + def write_failed(message: str): + QtWidgets.QMessageBox.warning(parent, "Import impact categories", message) + + write_thread.written.connect(after_write) + write_thread.failed.connect(write_failed) + + def write_cancelled(): + if overwrite: + QtWidgets.QMessageBox.information( + parent, + "Import cancelled", + "Import cancelled. Newly created impact categories from this run " + "were removed. Any categories already overwritten may remain updated.", + ) + else: + notify_import_cancelled("Import cancelled") + + run_thread_with_progress( + "Writing impact categories", + write_thread, + on_cancelled=write_cancelled, + ) + + link_thread.linked.connect(after_link) + run_thread_with_progress( + "Linking characterization factors", + link_thread, + on_cancelled=lambda: notify_import_cancelled("Import cancelled"), + ) diff --git a/activity_browser/app/actions/method/method_import_bw2io.py b/activity_browser/app/actions/method/method_import_bw2io.py new file mode 100644 index 000000000..fc0b5117b --- /dev/null +++ b/activity_browser/app/actions/method/method_import_bw2io.py @@ -0,0 +1,456 @@ +"""Import impact categories from bw2io LCIA files.""" +from __future__ import annotations + +from pathlib import Path +from typing import Callable, Sequence + +from qtpy import QtCore, QtWidgets +from qtpy.QtCore import Signal, SignalInstance + +from activity_browser import app +from activity_browser.app.actions.base import ABAction, exception_dialogs +from activity_browser.app.actions.method.method_import_ab import ( + MultiImportSetupDialog, + finalize_lcia_import, + notify_import_cancelled, +) +from activity_browser.bwutils.impact_categories import ( + ConflictMode, + join_tuple_path, + split_tuple_path, +) +from activity_browser.bwutils.impact_categories.bw2io_lcia_file import ( + load_bw2io_lcia_file, + read_bw2io_metadata_csv, + read_bw2io_metadata_xlsx, +) +from activity_browser.mod import bw2data as bd +from activity_browser.ui import widgets +from activity_browser.ui.core import threading +from activity_browser.app.dialogs import run_thread_with_progress +from activity_browser.ui.icons import qicons + + +class MethodImportBW2IO(ABAction): + """Import one or more bw2io impact-category files.""" + + icon = qicons.import_db + text = "From bw2io LCIA file (.xlsx/.csv)…" + tool_tip = "Import one or more bw2io LCIA Excel or CSV files" + + @classmethod + @exception_dialogs + def run(cls): + paths, _ = QtWidgets.QFileDialog.getOpenFileNames( + parent=app.main_window, + caption="Import bw2io LCIA file(s)", + filter="LCIA (*.xlsx *.csv);;Excel (*.xlsx);;CSV (*.csv);;All files (*.*)", + ) + if not paths: + return + + path_objs = [Path(p) for p in paths] + if len(path_objs) == 1: + cls._import_single(path_objs[0]) + else: + cls._import_many(path_objs) + + @classmethod + def _import_single(cls, path: Path): + prefill = _prefill_for_bw2io_path(path) + dialog = BW2IOMetadataDialog(prefill, parent=app.main_window) + if dialog.exec_() != QtWidgets.QDialog.Accepted: + return + + name = split_tuple_path(dialog.method_path) + if not name: + QtWidgets.QMessageBox.warning( + app.main_window, + "Import bw2io LCIA", + "Method name is required (use :: between parts).", + ) + return + + overwrite = False + if name in bd.methods: + conflict = BW2IOLciaFileConflictDialog(name, parent=app.main_window) + if conflict.exec_() != QtWidgets.QDialog.Accepted: + return + name = conflict.result_name + overwrite = conflict.overwrite + + bio = BiospherePickDialog(parent=app.main_window) + if bio.exec_() != QtWidgets.QDialog.Accepted: + return + + def after_load(data: list): + finalize_lcia_import( + data, + biosphere_name=bio.biosphere_name, + overwrite=overwrite, + ) + + load_bw2io_file_with_progress( + path, + name=name, + unit=dialog.unit, + description=dialog.description, + on_loaded=after_load, + ) + + @classmethod + def _import_many(cls, paths: list[Path]): + rows = [] + for path in paths: + prefill = _prefill_for_bw2io_path(path) + rows.append( + { + "path": path, + "method": prefill.get("method") or "", + "unit": prefill.get("unit") or "", + "description": prefill.get("description") or "", + } + ) + + review = BW2IOBatchMetadataDialog(rows, parent=app.main_window) + if review.exec_() != QtWidgets.QDialog.Accepted: + return + + def after_load(data: list): + if not data: + QtWidgets.QMessageBox.warning( + app.main_window, + "Import bw2io LCIA", + "No impact categories found in the selected files.", + ) + return + setup = MultiImportSetupDialog(data, parent=app.main_window) + if setup.exec_() != QtWidgets.QDialog.Accepted: + return + finalize_lcia_import( + setup.prepared_data, + biosphere_name=setup.biosphere_name, + overwrite=setup.conflict_mode == ConflictMode.OVERWRITE, + ) + + load_bw2io_files_with_progress(review.rows, on_loaded=after_load) + + +class BW2IOLciaFileConflictDialog(QtWidgets.QDialog): + """Overwrite / edit name / cancel when a bw2io impact-category file name already exists.""" + + result_name: tuple + overwrite: bool + + def __init__(self, name: tuple, parent=None): + super().__init__(parent) + self._original = tuple(name) + self.setWindowTitle("Impact category already exists") + self.name_edit = QtWidgets.QLineEdit(join_tuple_path(name)) + info = QtWidgets.QLabel( + f"{join_tuple_path(name)} already exists in this project." + ) + info.setWordWrap(True) + buttons = QtWidgets.QDialogButtonBox() + self.overwrite_btn = buttons.addButton( + "Overwrite", QtWidgets.QDialogButtonBox.AcceptRole + ) + self.use_name_btn = buttons.addButton( + "Use edited name", QtWidgets.QDialogButtonBox.AcceptRole + ) + buttons.addButton(QtWidgets.QDialogButtonBox.Cancel) + buttons.rejected.connect(self.reject) + self.overwrite_btn.clicked.connect(self._accept_overwrite) + self.use_name_btn.clicked.connect(self._accept_edit) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(info) + layout.addWidget(QtWidgets.QLabel("Name (:: parts):")) + layout.addWidget(self.name_edit) + layout.addWidget(buttons) + + def _accept_overwrite(self): + confirm = QtWidgets.QMessageBox.question( + self, + "Overwrite impact category", + f"Overwrite {join_tuple_path(self._original)}?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if confirm != QtWidgets.QMessageBox.Yes: + return + self.result_name = self._original + self.overwrite = True + self.accept() + + def _accept_edit(self): + name = split_tuple_path(self.name_edit.text().strip()) + if not name: + QtWidgets.QMessageBox.warning(self, "Edit name", "Name cannot be empty.") + return + if name in bd.methods and name != self._original: + QtWidgets.QMessageBox.warning( + self, + "Edit name", + "That name already exists. Choose another or overwrite the original.", + ) + return + self.result_name = name + self.overwrite = name == self._original and name in bd.methods + if self.overwrite: + self._accept_overwrite() + return + self.accept() + + +class BiospherePickDialog(QtWidgets.QDialog): + biosphere_name: str + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Choose biosphere database") + self.db_chooser = widgets.ABComboBox.get_database_combobox(self) + default_bio = bd.config.biosphere + idx = self.db_chooser.findText(default_bio) + if idx >= 0: + self.db_chooser.setCurrentIndex(idx) + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(self.db_chooser) + layout.addWidget(buttons) + + def accept(self): + self.biosphere_name = self.db_chooser.currentText() + super().accept() + + +class BW2IOMetadataDialog(QtWidgets.QDialog): + method_path: str + unit: str + description: str + + def __init__(self, prefill: dict, parent=None): + super().__init__(parent) + self.setWindowTitle("bw2io impact category metadata") + self.method_edit = QtWidgets.QLineEdit(prefill.get("method") or "") + self.method_edit.setPlaceholderText("My method::climate change::GWP100") + self.unit_edit = QtWidgets.QLineEdit(prefill.get("unit") or "") + self.description_edit = QtWidgets.QPlainTextEdit( + prefill.get("description") or "" + ) + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout = QtWidgets.QFormLayout(self) + layout.addRow("Method (:: parts):", self.method_edit) + layout.addRow("Unit:", self.unit_edit) + layout.addRow("Description:", self.description_edit) + layout.addRow(buttons) + + def accept(self): + self.method_path = self.method_edit.text().strip() + self.unit = self.unit_edit.text().strip() + self.description = self.description_edit.toPlainText().strip() + super().accept() + + +class BW2IOBatchMetadataDialog(QtWidgets.QDialog): + """Edit method / unit / description for several bw2io impact-category files.""" + + rows: list[dict] + + def __init__(self, rows: list[dict], parent=None): + super().__init__(parent) + self._rows = [dict(r) for r in rows] + self.setWindowTitle("bw2io impact category metadata") + self.resize(820, min(120 + 28 * len(rows), 520)) + + self.table = QtWidgets.QTableWidget(len(rows), 4) + self.table.setHorizontalHeaderLabels( + ["File", "Method (:: parts)", "Unit", "Description"] + ) + self.table.horizontalHeader().setStretchLastSection(True) + self.table.horizontalHeader().setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeToContents + ) + for i, row in enumerate(self._rows): + file_item = QtWidgets.QTableWidgetItem(Path(row["path"]).name) + file_item.setFlags(file_item.flags() & ~QtCore.Qt.ItemIsEditable) + self.table.setItem(i, 0, file_item) + self.table.setItem(i, 1, QtWidgets.QTableWidgetItem(row.get("method") or "")) + self.table.setItem(i, 2, QtWidgets.QTableWidgetItem(row.get("unit") or "")) + self.table.setItem( + i, 3, QtWidgets.QTableWidgetItem(row.get("description") or "") + ) + + info = QtWidgets.QLabel( + f"Review metadata for {len(rows)} bw2io file(s). " + "Method names use ::between Brightway parts." + ) + info.setWordWrap(True) + buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(info) + layout.addWidget(self.table) + layout.addWidget(buttons) + + def accept(self): + seen: set[tuple] = set() + out: list[dict] = [] + for i, row in enumerate(self._rows): + method_text = (self.table.item(i, 1).text() or "").strip() + name = split_tuple_path(method_text) + if not name: + QtWidgets.QMessageBox.warning( + self, + "bw2io metadata", + f"Row {i + 1} ({Path(row['path']).name}): method name is required.", + ) + return + if name in seen: + QtWidgets.QMessageBox.warning( + self, + "bw2io metadata", + f"Duplicate method name in the selection:\n{join_tuple_path(name)}", + ) + return + seen.add(name) + out.append( + { + "path": row["path"], + "name": name, + "unit": (self.table.item(i, 2).text() or "").strip(), + "description": (self.table.item(i, 3).text() or "").strip(), + } + ) + self.rows = out + super().accept() + + +def _prefill_for_bw2io_path(path: Path) -> dict[str, str]: + prefill = {"method": "", "unit": "", "description": ""} + if path.suffix.lower() in {".xlsx", ".xls"}: + prefill.update(read_bw2io_metadata_xlsx(path) or {}) + else: + sibling = path.with_name("metadata.csv") + row = read_bw2io_metadata_csv(sibling, cf_filename=path.name) + if row: + prefill.update(row) + return prefill + + +class LoadBW2IOFileThread(threading.ABThread): + loaded: SignalInstance = Signal(object) + failed: SignalInstance = Signal(str) + + path: Path + name: tuple + unit: str + description: str + + def run_safely(self): + try: + if self.ab_cancel_requested(): + return + data = load_bw2io_lcia_file( + self.path, + name=self.name, + unit=self.unit, + description=self.description, + ) + except (ValueError, FileNotFoundError) as exc: + self.failed.emit(str(exc)) + return + if self.ab_cancel_requested(): + return + self.loaded.emit(data) + + +class LoadBW2IOFilesThread(threading.ABThread): + """Load several bw2io impact-category files into one importer-shaped list.""" + + loaded: SignalInstance = Signal(object) + failed: SignalInstance = Signal(str) + + specs: list + + def run_safely(self): + import tqdm + + combined: list = [] + try: + for spec in tqdm.tqdm( + self.specs, desc="Loading bw2io files", total=len(self.specs) + ): + if self.ab_cancel_requested(): + return + combined.extend( + load_bw2io_lcia_file( + Path(spec["path"]), + name=tuple(spec["name"]), + unit=spec.get("unit") or "", + description=spec.get("description") or "", + ) + ) + except (ValueError, FileNotFoundError) as exc: + self.failed.emit(str(exc)) + return + if self.ab_cancel_requested(): + return + self.loaded.emit(combined) + + +def load_bw2io_file_with_progress( + path: Path, + *, + name: tuple, + unit: str, + description: str, + on_loaded: Callable[[list], None], +) -> None: + thread = LoadBW2IOFileThread(app.application) + thread.path = path + thread.name = name + thread.unit = unit + thread.description = description + + def _fail(message: str): + QtWidgets.QMessageBox.warning(app.main_window, "Import bw2io LCIA", message) + + thread.failed.connect(_fail) + thread.loaded.connect(on_loaded) + run_thread_with_progress( + "Loading impact category file", + thread, + on_cancelled=lambda: notify_import_cancelled("Import cancelled"), + ) + + +def load_bw2io_files_with_progress( + specs: Sequence[dict], + *, + on_loaded: Callable[[list], None], +) -> None: + thread = LoadBW2IOFilesThread(app.application) + thread.specs = list(specs) + + def _fail(message: str): + QtWidgets.QMessageBox.warning(app.main_window, "Import bw2io LCIA", message) + + thread.failed.connect(_fail) + thread.loaded.connect(on_loaded) + run_thread_with_progress( + "Loading impact category files", + thread, + on_cancelled=lambda: notify_import_cancelled("Import cancelled"), + ) diff --git a/activity_browser/app/actions/method/importer/method_importer_ecoinvent.py b/activity_browser/app/actions/method/method_import_ecoinvent.py similarity index 84% rename from activity_browser/app/actions/method/importer/method_importer_ecoinvent.py rename to activity_browser/app/actions/method/method_import_ecoinvent.py index 2300f0d80..a7b5afc1a 100644 --- a/activity_browser/app/actions/method/importer/method_importer_ecoinvent.py +++ b/activity_browser/app/actions/method/method_import_ecoinvent.py @@ -7,18 +7,19 @@ from activity_browser.mod import bw2data as bd from activity_browser.app.actions.base import ABAction, exception_dialogs from activity_browser.ui import icons, widgets -from activity_browser.bwutils.io.ecoinvent_lcia_importer import EcoinventLCIAImporter +from activity_browser.ui.dialogs import ABProgressDialog +from activity_browser.bwutils.impact_categories import EcoinventLCIAImporter from activity_browser.ui.core import threading -class MethodImporterEcoinvent(ABAction): +class MethodImportEcoinvent(ABAction): """ABAction to import methods from ecoinvent""" icon = icons.qicons.import_db - text = "Import from ecoinvent excel..." - tool_tip = "Import methods from ecoinvent excel format" + text = "From ecoinvent Excel…" + tool_tip = "Import impact categories from an ecoinvent LCIA Implementation Excel workbook" @classmethod @exception_dialogs @@ -38,7 +39,7 @@ def run(cls): extract_thread.loaded.connect(cls.write_database) # show progress dialog for importing the excel - progress_dialog = widgets.ABProgressDialog.get_connected_dialog("Importing Database") + progress_dialog = ABProgressDialog.get_connected_dialog("Importing Database") extract_thread.finished.connect(progress_dialog.deleteLater) extract_thread.start() @@ -57,7 +58,7 @@ def write_database(importer: EcoinventLCIAImporter): importer_thread.prepend = import_dialog.prepend # setup a progress dialog - progress_dialog = widgets.ABProgressDialog.get_connected_dialog("Importing Impact Categories") + progress_dialog = ABProgressDialog.get_connected_dialog("Importing Impact Categories") importer_thread.finished.connect(progress_dialog.deleteLater) progress_dialog.show() @@ -75,7 +76,10 @@ def __init__(self, importer: EcoinventLCIAImporter, parent=None): self.setWindowTitle("Import methods from ecoinvent Excel") self.db_chooser = widgets.ABComboBox.get_database_combobox(self) - self.button_comp = composites.HorizontalButtonsComposite("Cancel", "*OK") + self.buttons = QtWidgets.QDialogButtonBox( + QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel + ) + self.ok_button = self.buttons.button(QtWidgets.QDialogButtonBox.Ok) self.info = QtWidgets.QLabel() self.info.setWordWrap(True) @@ -88,8 +92,8 @@ def __init__(self, importer: EcoinventLCIAImporter, parent=None): self.prepend_textbox.textChanged.connect(self.check_overwrite) # Connect the necessary signals - self.button_comp["OK"].clicked.connect(self.accept) - self.button_comp["Cancel"].clicked.connect(self.reject) + self.buttons.accepted.connect(self.accept) + self.buttons.rejected.connect(self.reject) # Create final layout layout = QtWidgets.QVBoxLayout() @@ -98,7 +102,7 @@ def __init__(self, importer: EcoinventLCIAImporter, parent=None): layout.addWidget(self.prepend_label) layout.addWidget(self.prepend_textbox) layout.addWidget(self.info) - layout.addWidget(self.button_comp) + layout.addWidget(self.buttons) # Set the dialog layout self.setLayout(layout) @@ -127,7 +131,7 @@ def check_overwrite(self, prepend=None) -> int: def validate(self): """Validate the user input and enable the OK button if all is clear""" valid = True - self.button_comp["OK"].setEnabled(valid) + self.ok_button.setEnabled(valid) def accept(self): """Correctly set the dialog's attributes for further use in the action""" diff --git a/activity_browser/app/dialogs/__init__.py b/activity_browser/app/dialogs/__init__.py index 5a337f238..def17ce93 100644 --- a/activity_browser/app/dialogs/__init__.py +++ b/activity_browser/app/dialogs/__init__.py @@ -1,3 +1,11 @@ from .import_preview_dialog import ImportPreviewDialog from .node_select_dialog import NodeSelectDialog from .database_select_dialog import DatabaseSelectDialog +from .thread_progress import run_thread_with_progress + +__all__ = [ + "DatabaseSelectDialog", + "ImportPreviewDialog", + "NodeSelectDialog", + "run_thread_with_progress", +] diff --git a/activity_browser/app/dialogs/thread_progress.py b/activity_browser/app/dialogs/thread_progress.py new file mode 100644 index 000000000..ba7f2f5c6 --- /dev/null +++ b/activity_browser/app/dialogs/thread_progress.py @@ -0,0 +1,38 @@ +"""App-layer sticky-cancel progress starter for ABThread jobs.""" +from __future__ import annotations + +from activity_browser.ui.dialogs import ABProgressDialog + + +def run_thread_with_progress(title: str, thread, *, on_cancelled=None) -> None: + """ + Show a cancellable ABProgressDialog for an ABThread and start it. + + Sticky cancel: closing after success must not look like a user cancel. + ``on_cancelled`` runs only if the thread reported ``ab_cancel_requested``. + """ + progress = ABProgressDialog.get_connected_dialog(title, cancellable=True) + thread.connect_progress_dialog(progress) + + def request_cancel(): + thread.request_ab_cancel() + progress.mark_cancelled() + progress.setLabelText("Cancelling…") + + progress.canceled.connect(request_cancel) + + def cleanup(): + was_cancelled = thread.ab_cancel_requested() + try: + progress.canceled.disconnect(request_cancel) + except (RuntimeError, TypeError): + pass + progress.detach() + progress.close() + progress.deleteLater() + if was_cancelled and on_cancelled is not None: + on_cancelled() + + thread.finished.connect(cleanup) + progress.show() + thread.start() diff --git a/activity_browser/app/menu_bar.py b/activity_browser/app/menu_bar.py index 9d8297d00..a4ee90f7f 100644 --- a/activity_browser/app/menu_bar.py +++ b/activity_browser/app/menu_bar.py @@ -89,7 +89,7 @@ def __init__(self, parent=None) -> None: class ImpactCategoriesMenu(QtWidgets.QMenu): - """Impact category (LCIA method) import.""" + """Impact category (LCIA method) import/export.""" def __init__(self, parent=None) -> None: super().__init__(parent) @@ -97,7 +97,12 @@ def __init__(self, parent=None) -> None: self.setTitle("&Impact categories") self.import_menu = ImportICMenu(self) + self.export_menu = ExportICMenu(self) + self.get_template_action = app.actions.MethodGetTemplate.get_QAction(parent=self) + self.addMenu(self.import_menu) + self.addMenu(self.export_menu) + self.addAction(self.get_template_action) class ProjectNewMenu(QtWidgets.QMenu): @@ -312,8 +317,21 @@ def __init__(self, parent=None) -> None: self.setTitle("Import") self.setIcon(qicons.import_db) - self.import_from_ei_excel_action = app.actions.MethodImporterEcoinvent.get_QAction(parent=self) - self.import_from_bw2io_action = app.actions.MethodImporterBW2IO.get_QAction(parent=self) + self.import_from_ab_action = app.actions.MethodImportAB.get_QAction(parent=self) + self.import_from_bw2io_file_action = app.actions.MethodImportBW2IO.get_QAction(parent=self) + self.import_from_ei_excel_action = app.actions.MethodImportEcoinvent.get_QAction(parent=self) + self.addAction(self.import_from_ab_action) + self.addAction(self.import_from_bw2io_file_action) self.addAction(self.import_from_ei_excel_action) - self.addAction(self.import_from_bw2io_action) + + +class ExportICMenu(QtWidgets.QMenu): + def __init__(self, parent=None) -> None: + super().__init__(parent=parent) + self.setTitle("Export") + + self.export_to_ab_action = app.actions.MethodExportAB.get_QAction(parent=self) + self.export_to_bw2io_action = app.actions.MethodExportBW2IO.get_QAction(parent=self) + self.addAction(self.export_to_ab_action) + self.addAction(self.export_to_bw2io_action) diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index f909a8581..66d8c7e11 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -4,10 +4,66 @@ import bw2data as bd import pandas as pd +from typing import List, Optional + from activity_browser import app, app from activity_browser.ui import widgets, core, delegates +def live_impact_category_selection() -> Optional[List[tuple]]: + """ + Return selected impact-category names from the Impact categories pane. + + On any failure (no main window, pane missing, etc.) return ``None`` so + callers can fall back to the empty-selection / export-all prompt. + """ + try: + window = app.main_window + if window is None: + return None + for pane in window.panes(): + if isinstance(pane, ImpactCategoriesPane): + selected = pane.view.selected_impact_categories + return list(selected) if selected else None + return None + except Exception: + return None + + +def resolve_methods_for_export( + method_names: Optional[List[tuple]] = None, +) -> Optional[List[tuple]]: + """ + Resolve which impact categories to export. + + If ``method_names`` is None, use the live pane selection. When nothing is + selected, ask whether to export all. Returns ``None`` if the user cancels + or the project has no methods. + """ + if method_names is None: + method_names = live_impact_category_selection() + if not method_names: + choice = QtWidgets.QMessageBox.question( + app.main_window, + "Export impact categories", + "No impact categories are selected.\n\n" + "Export all impact categories in this project?", + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if choice != QtWidgets.QMessageBox.Yes: + return None + method_names = list(bd.methods) + if not method_names: + QtWidgets.QMessageBox.information( + app.main_window, + "Export impact categories", + "There are no impact categories to export.", + ) + return None + return list(method_names) + + class ImpactCategoriesPane(widgets.ABAbstractPane): title = "Impact Categories" unique = True @@ -90,6 +146,25 @@ class ContextMenu(widgets.ABMenu): text="Rename impact category", enable=len(p.selected_impact_categories) == 1 ), + lambda m: m.addSeparator(), + lambda m, p: m.addMenu(ImpactCategoriesView.ExportContextMenu(parent=p)), + ] + + class ExportContextMenu(widgets.ABMenu): + menuSetup = [ + lambda m: m.setTitle("Export"), + lambda m, p: m.add( + app.actions.MethodExportAB, + p.selected_impact_categories, + text="To AB LCIA file (.xlsx/.csv)…", + enable=len(p.selected_impact_categories) > 0, + ), + lambda m, p: m.add( + app.actions.MethodExportBW2IO, + p.selected_impact_categories, + text="To bw2io LCIA file (.xlsx/.csv)…", + enable=len(p.selected_impact_categories) > 0, + ), ] @property diff --git a/activity_browser/bwutils/impact_categories/__init__.py b/activity_browser/bwutils/impact_categories/__init__.py new file mode 100644 index 000000000..7687e283a --- /dev/null +++ b/activity_browser/bwutils/impact_categories/__init__.py @@ -0,0 +1,71 @@ +"""Impact category (LCIA method) interchange helpers.""" + +from .ab_lcia_file import ( + CFS_COLUMNS, + CFS_SHEET, + CFS_SUFFIX, + ABLCIAImporter, + IC_COLUMNS, + IMPACT_CATEGORIES_SHEET, + META_SUFFIX, + ab_csv_sibling_path, + export_methods_ab_csv_pair, + export_methods_ab_xlsx, + import_ab_methods, + load_ab_csv_pair, + load_ab_xlsx, + resolve_ab_csv_pair, +) +from .bw2io_lcia_file import ( + load_bw2io_lcia_file, + method_name_to_filename_stem, +) +from .common import ( + UNCERTAINTY_FIELDS, + CancelledError, + ConflictMode, + ImportStats, + activity_for_cf_key, + apply_name_conflicts, + cf_amount_and_uncertainty, + drop_unlinked_exchanges, + exchange_link_counts, + join_tuple_path, + raise_if_cancelled, + split_tuple_path, + unlinked_exchanges, +) +from .ecoinvent_lcia import EcoinventLCIAImporter + +__all__ = [ + "ABLCIAImporter", + "CFS_COLUMNS", + "CFS_SHEET", + "CFS_SUFFIX", + "CancelledError", + "ConflictMode", + "EcoinventLCIAImporter", + "IC_COLUMNS", + "IMPACT_CATEGORIES_SHEET", + "ImportStats", + "META_SUFFIX", + "UNCERTAINTY_FIELDS", + "ab_csv_sibling_path", + "activity_for_cf_key", + "apply_name_conflicts", + "cf_amount_and_uncertainty", + "drop_unlinked_exchanges", + "exchange_link_counts", + "export_methods_ab_csv_pair", + "export_methods_ab_xlsx", + "import_ab_methods", + "join_tuple_path", + "load_ab_csv_pair", + "load_ab_xlsx", + "load_bw2io_lcia_file", + "method_name_to_filename_stem", + "raise_if_cancelled", + "resolve_ab_csv_pair", + "split_tuple_path", + "unlinked_exchanges", +] diff --git a/activity_browser/bwutils/impact_categories/ab_lcia_file.py b/activity_browser/bwutils/impact_categories/ab_lcia_file.py new file mode 100644 index 000000000..898542506 --- /dev/null +++ b/activity_browser/bwutils/impact_categories/ab_lcia_file.py @@ -0,0 +1,372 @@ +"""AB impact-category file load/export and shared prepared-dataset importer. + +``ABLCIAImporter`` links and writes prepared LCIA datasets (AB-shaped +``list[dict]``). AB and bw2io LCIA file loaders both feed it. +""" +from __future__ import annotations + +import functools +from pathlib import Path +from typing import Any, Iterable, Sequence + +import bw2data as bd +import pandas as pd +import tqdm +from bw2data import Database, Method, config, methods +from bw2io.importers.base_lcia import LCIAImporter +from bw2io.strategies import ( + convert_uncertainty_types_to_integers, + drop_falsey_uncertainty_fields_but_keep_zeros, + drop_unspecified_subcategories, + link_iterable_by_fields, + set_biosphere_type, +) + +from .common import ( + UNCERTAINTY_FIELDS, + CancelledError, + ConflictMode, + ImportStats, + activity_for_cf_key, + apply_name_conflicts, + cell_str, + cf_amount_and_uncertainty, + drop_unlinked_exchanges, + join_tuple_path, + raise_if_cancelled, + split_tuple_path, + uncertainty_from_series, + unlinked_exchanges, +) + +CFS_SHEET = "CFs" +IMPACT_CATEGORIES_SHEET = "Impact categories" +CFS_COLUMNS = ("method", "flow", "amount", *UNCERTAINTY_FIELDS) +IC_COLUMNS = ("method", "unit", "description") +CFS_SUFFIX = ".cfs.csv" +META_SUFFIX = ".metadata.csv" + + +def _flow_path_for_activity(act) -> str: + cats = tuple(act.get("categories") or ()) + return join_tuple_path((act.get("name", ""), *cats)) + + +def _ab_csv_directory_and_stem(base: Path) -> tuple[Path, str]: + name, lower = base.name, base.name.lower() + for suffix in (CFS_SUFFIX, META_SUFFIX): + if lower.endswith(suffix): + return base.parent, name[: -len(suffix)] + return base.parent, base.name + + +def methods_to_ab_records( + method_names: Iterable[tuple], + *, + cancel_check=None, +) -> tuple[pd.DataFrame, pd.DataFrame]: + """Build CFs and Impact categories dataframes for the given method keys.""" + ic_rows: list[dict] = [] + cf_rows: list[dict] = [] + names = [tuple(name) for name in method_names] + for name in tqdm.tqdm(names, desc="Reading impact categories", total=len(names)): + raise_if_cancelled(cancel_check) + meta = methods.get(name) or {} + method_path = join_tuple_path(name) + ic_rows.append( + { + "method": method_path, + "unit": meta.get("unit") or "", + "description": meta.get("description") or "", + } + ) + for key, cf_data in Method(name).load(): + row = { + "method": method_path, + "flow": _flow_path_for_activity(activity_for_cf_key(key)), + **cf_amount_and_uncertainty(cf_data), + } + for field in UNCERTAINTY_FIELDS: + row.setdefault(field, None) + cf_rows.append(row) + raise_if_cancelled(cancel_check) + return ( + pd.DataFrame(cf_rows, columns=list(CFS_COLUMNS)), + pd.DataFrame(ic_rows, columns=list(IC_COLUMNS)), + ) + + +def export_methods_ab_xlsx( + method_names: Sequence[tuple], + path: str | Path, + *, + cancel_check=None, +) -> Path: + path = Path(path) + cfs, ics = methods_to_ab_records(method_names, cancel_check=cancel_check) + raise_if_cancelled(cancel_check) + with pd.ExcelWriter(path, engine="openpyxl") as writer: + cfs.to_excel(writer, sheet_name=CFS_SHEET, index=False) + ics.to_excel(writer, sheet_name=IMPACT_CATEGORIES_SHEET, index=False) + return path + + +def ab_csv_sibling_path(path: str | Path) -> Path | None: + """Return the expected sibling path for an AB CSV pair member, or None.""" + path = Path(path) + name, lower = path.name, path.name.lower() + if lower.endswith(CFS_SUFFIX): + return path.with_name(name[: -len(CFS_SUFFIX)] + META_SUFFIX) + if lower.endswith(META_SUFFIX): + return path.with_name(name[: -len(META_SUFFIX)] + CFS_SUFFIX) + return None + + +def resolve_ab_csv_pair( + path: str | Path, + *, + other_path: str | Path | None = None, +) -> tuple[Path, Path]: + """Return (cfs_path, metadata_path) for an AB CSV import.""" + path = Path(path) + lower = path.name.lower() + if lower.endswith(CFS_SUFFIX): + cfs_path, ic_path = path, Path(other_path) if other_path else ab_csv_sibling_path(path) + elif lower.endswith(META_SUFFIX): + ic_path, cfs_path = path, Path(other_path) if other_path else ab_csv_sibling_path(path) + else: + raise ValueError("AB CSV import expects a '.cfs.csv' or '.metadata.csv' file") + if cfs_path is None or ic_path is None: + raise FileNotFoundError("Matching AB CSV sibling file not found") + if not cfs_path.is_file(): + raise FileNotFoundError(f"CF file not found: {cfs_path}") + if not ic_path.is_file(): + raise FileNotFoundError(f"Metadata file not found: {ic_path}") + return cfs_path, ic_path + + +def export_methods_ab_csv_pair( + method_names: Sequence[tuple], + base_path: str | Path, + *, + cancel_check=None, +) -> tuple[Path, Path]: + """Write AB CSV pair. ``base_path`` is a directory + stem (no required suffix).""" + directory, stem = _ab_csv_directory_and_stem(Path(base_path)) + cfs_path = directory / f"{stem}{CFS_SUFFIX}" + ic_path = directory / f"{stem}{META_SUFFIX}" + cfs, ics = methods_to_ab_records(method_names, cancel_check=cancel_check) + raise_if_cancelled(cancel_check) + cfs.to_csv(cfs_path, index=False) + ics.to_csv(ic_path, index=False) + return cfs_path, ic_path + + +def _exchange_from_cf_row(row: pd.Series) -> dict: + parts = split_tuple_path(row["flow"]) + if not parts: + raise ValueError("CF row missing flow path") + return { + "name": parts[0], + "categories": tuple(parts[1:]), + "amount": float(row["amount"]), + **uncertainty_from_series(row), + } + + +def _method_dataset(name: tuple, unit: str, description: str, filename: str, exchanges: list) -> dict: + return { + "name": name, + "unit": unit, + "description": description, + "filename": filename, + "exchanges": exchanges, + } + + +def _records_from_frames(cfs: pd.DataFrame, ics: pd.DataFrame, filename: str) -> list[dict]: + for col in ("method", "flow", "amount"): + if col not in cfs.columns: + raise ValueError(f"AB LCIA file missing CF column '{col}'") + for col in ("method", "unit", "description"): + if col not in ics.columns: + raise ValueError(f"AB LCIA file missing Impact categories column '{col}'") + + meta_by_method: dict[tuple, dict[str, str]] = {} + for _, row in ics.iterrows(): + key = split_tuple_path(row["method"]) + if key: + meta_by_method[key] = { + "unit": cell_str(row.get("unit")), + "description": cell_str(row.get("description")), + } + + grouped: dict[tuple, list] = {} + for _, row in cfs.iterrows(): + if pd.isna(row.get("amount")): + continue + key = split_tuple_path(row["method"]) + if key: + grouped.setdefault(key, []).append(_exchange_from_cf_row(row)) + + empty = {"unit": "", "description": ""} + data = [] + for key, exchanges in grouped.items(): + meta = meta_by_method.pop(key, empty) + data.append( + _method_dataset(key, meta["unit"], meta["description"], filename, exchanges) + ) + for key, meta in meta_by_method.items(): + data.append(_method_dataset(key, meta["unit"], meta["description"], filename, [])) + return data + + +def load_ab_xlsx(path: str | Path) -> list[dict]: + """Parse an AB impact-category workbook into LCIAImporter-shaped datasets.""" + path = Path(path) + return _records_from_frames( + pd.read_excel(path, sheet_name=CFS_SHEET), + pd.read_excel(path, sheet_name=IMPACT_CATEGORIES_SHEET), + path.name, + ) + + +def load_ab_csv_pair( + path: str | Path, + *, + other_path: str | Path | None = None, +) -> list[dict]: + """Parse an AB CSV sibling pair into LCIAImporter-shaped datasets.""" + cfs_path, ic_path = resolve_ab_csv_pair(path, other_path=other_path) + return _records_from_frames( + pd.read_csv(cfs_path, comment="#"), + pd.read_csv(ic_path, comment="#"), + cfs_path.name, + ) + + +def _reformat_cfs_with_uncertainty(exchanges: list[dict]) -> list[tuple]: + rows = [] + for obj in exchanges: + if "input" not in obj: + continue + data = {"amount": obj["amount"]} + for field in UNCERTAINTY_FIELDS: + if field in obj and obj[field] is not None: + data[field] = obj[field] + rows.append((obj["input"], data if len(data) > 1 else obj["amount"])) + return rows + + +class ABLCIAImporter(LCIAImporter): + """ + Shared write/link path for prepared LCIA datasets (bw2io ``LCIAImporter``). + + Accepts AB-shaped ``list[dict]`` from AB or bw2io LCIA file loaders. + Preserves CF uncertainty on write. + """ + + def __init__(self, data: list[dict], biosphere: str | None = None): + self.applied_strategies = [] + self.filepath = "(ab-lcia)" + self.biosphere_name = biosphere or config.biosphere + if self.biosphere_name not in bd.databases: + raise ValueError(f"Can't find biosphere database {self.biosphere_name}") + self.data = data + self.strategies = [ + set_biosphere_type, + drop_unspecified_subcategories, + functools.partial( + link_iterable_by_fields, + other=Database(self.biosphere_name), + fields=("name", "categories"), + ), + drop_falsey_uncertainty_fields_but_keep_zeros, + convert_uncertainty_types_to_integers, + ] + + def _reformat_cfs(self, ds): + return _reformat_cfs_with_uncertainty(ds) + + def apply_strategies(self, strategies=None, verbose=False, cancel_check=None): + for strategy in tqdm.tqdm( + strategies if strategies is not None else self.strategies, + desc="Applying strategies", + ): + raise_if_cancelled(cancel_check) + self.apply_strategy(strategy, verbose=verbose) + raise_if_cancelled(cancel_check) + + def write_methods(self, overwrite=False, verbose=True, cancel_check=None): + num_methods, num_cfs, num_unlinked = self.statistics(False) + if num_unlinked: + raise ValueError( + f"Can't write unlinked methods ({num_unlinked} unlinked cfs)" + ) + prepared = [] + for ds in tqdm.tqdm(self.data, desc="Preparing impact categories"): + raise_if_cancelled(cancel_check) + prepared.append((ds, self._reformat_cfs(ds["exchanges"]))) + + raise_if_cancelled(cancel_check) + written_names: list[tuple] = [] + preexisting = set(methods) + try: + for ds, cfs in tqdm.tqdm(prepared, desc="Writing impact categories"): + raise_if_cancelled(cancel_check) + name = tuple(ds["name"]) + if name in methods: + if not overwrite: + raise ValueError( + f"Method {name} already exists. Use overwrite=True" + ) + del methods[name] + method = Method(name) + method.register( + description=ds.get("description") or "", + filename=ds.get("filename") or "", + unit=ds.get("unit") or "", + ) + method.write(cfs) + written_names.append(name) + except CancelledError: + for name in written_names: + if name not in preexisting: + methods.pop(name, None) + raise + if verbose: + print( + f"Wrote {num_methods} LCIA methods with {num_cfs} characterization factors" + ) + + +def import_ab_methods( + data: list[dict], + *, + biosphere_name: str, + conflict_mode: ConflictMode = ConflictMode.SKIP, + prefix: str | None = None, + renames: dict[tuple, tuple] | None = None, + drop_unlinked: bool = False, +) -> ImportStats: + """Link and write prepared method datasets into the current project.""" + prepared = apply_name_conflicts( + data, + set(methods), + mode=conflict_mode, + prefix=prefix, + renames=renames, + ) + skipped = len(data) - len(prepared) + importer = ABLCIAImporter(prepared, biosphere=biosphere_name) + importer.apply_strategies() + unlinked = len(unlinked_exchanges(importer.data)) + if unlinked and not drop_unlinked: + return ImportStats(written=0, skipped=skipped, unlinked=unlinked) + if drop_unlinked: + importer.data = drop_unlinked_exchanges(importer.data) + unlinked = 0 + importer.write_methods( + overwrite=conflict_mode == ConflictMode.OVERWRITE, verbose=False + ) + return ImportStats(written=len(importer.data), skipped=skipped, unlinked=unlinked) diff --git a/activity_browser/bwutils/impact_categories/bw2io_lcia_file.py b/activity_browser/bwutils/impact_categories/bw2io_lcia_file.py new file mode 100644 index 000000000..1de1d4554 --- /dev/null +++ b/activity_browser/bwutils/impact_categories/bw2io_lcia_file.py @@ -0,0 +1,182 @@ +"""bw2io impact-category file load/export (CF table + AB metadata helpers).""" +from __future__ import annotations + +from pathlib import Path +from typing import Sequence + +import bw2data as bd +import pandas as pd +import tqdm + +from .common import ( + UNCERTAINTY_FIELDS, + activity_for_cf_key, + cell_str, + cf_amount_and_uncertainty, + join_tuple_path, + raise_if_cancelled, + split_tuple_path, + uncertainty_from_series, +) + +BW2IO_CF_COLUMNS = ("name", "categories", "amount", *UNCERTAINTY_FIELDS) +BW2IO_META_COLUMNS = ("filename", "method", "unit", "description") +_FORBIDDEN_FILENAME_CHARS = '<>:"/\\|?*' + + +def method_name_to_filename_stem(name: tuple) -> str: + """ + Build a cross-platform filename stem from a Brightway method key. + + Tuple parts are joined with ``__`` (``::`` is illegal on Windows). Remaining + forbidden characters are replaced with ``-``. + """ + parts: list[str] = [] + for part in name: + text = str(part).strip() + for ch in _FORBIDDEN_FILENAME_CHARS: + text = text.replace(ch, "-") + text = "".join(c for c in text if ord(c) >= 32).rstrip(" .") + parts.append(text or "part") + stem = "__".join(parts) if parts else "method" + return (stem[:200].rstrip(" .") if len(stem) > 200 else stem) or "method" + + +def _method_meta(name: tuple, *, filename: str = "") -> dict[str, str]: + meta = bd.methods.get(name) or {} + return { + "filename": filename, + "method": join_tuple_path(name), + "unit": meta.get("unit") or "", + "description": meta.get("description") or "", + } + + +def _cf_frame_for_method(name: tuple) -> pd.DataFrame: + rows = [] + for key, cf_data in bd.Method(name).load(): + act = activity_for_cf_key(key) + cats = tuple(act.get("categories") or ()) + row = { + "name": act.get("name", ""), + "categories": join_tuple_path(cats) if cats else "", + **cf_amount_and_uncertainty(cf_data), + } + for field in UNCERTAINTY_FIELDS: + row.setdefault(field, None) + rows.append(row) + return pd.DataFrame(rows, columns=list(BW2IO_CF_COLUMNS)) + + +def export_method_bw2io_xlsx(name: tuple, path: str | Path) -> Path: + path = Path(path) + with pd.ExcelWriter(path, engine="openpyxl") as writer: + _cf_frame_for_method(name).to_excel(writer, sheet_name="CFs", index=False) + pd.DataFrame( + [_method_meta(name, filename=path.name)], + columns=list(BW2IO_META_COLUMNS), + ).to_excel(writer, sheet_name="metadata", index=False) + return path + + +def export_methods_bw2io_csv_batch( + method_names: Sequence[tuple], + directory: str | Path, + *, + metadata_name: str = "metadata.csv", + cancel_check=None, +) -> list[Path]: + directory = Path(directory) + directory.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + meta_rows = [] + for name in tqdm.tqdm(method_names, desc="Exporting bw2io CSV"): + raise_if_cancelled(cancel_check) + cf_name = f"{method_name_to_filename_stem(name)}.csv" + cf_path = directory / cf_name + _cf_frame_for_method(name).to_csv(cf_path, index=False) + written.append(cf_path) + meta_rows.append(_method_meta(name, filename=cf_name)) + raise_if_cancelled(cancel_check) + meta_path = directory / metadata_name + pd.DataFrame(meta_rows, columns=list(BW2IO_META_COLUMNS)).to_csv( + meta_path, index=False + ) + written.append(meta_path) + return written + + +def _row_to_meta(row: pd.Series) -> dict[str, str]: + return {key: cell_str(row.get(key)) for key in BW2IO_META_COLUMNS} + + +def read_bw2io_metadata_xlsx(path: str | Path) -> dict[str, str] | None: + try: + meta = pd.read_excel(path, sheet_name="metadata") + except ValueError: + return None + return None if meta.empty else _row_to_meta(meta.iloc[0]) + + +def read_bw2io_metadata_csv( + metadata_path: str | Path, + *, + cf_filename: str | None = None, +) -> dict[str, str] | None: + """ + Prefill metadata for a CF CSV. + + Match ``filename`` to ``cf_filename`` when that column exists; if there is + exactly one row, use it; otherwise return ``None``. + """ + path = Path(metadata_path) + if not path.is_file(): + return None + meta = pd.read_csv(path, comment="#") + if meta.empty: + return None + if cf_filename and "filename" in meta.columns: + match = meta[meta["filename"].astype(str) == str(cf_filename)] + if len(match) >= 1: + return _row_to_meta(match.iloc[0]) + return None + return _row_to_meta(meta.iloc[0]) if len(meta) == 1 else None + + +def load_bw2io_lcia_file( + path: str | Path, + *, + name: tuple, + unit: str, + description: str, +) -> list[dict]: + """Parse one bw2io CF table into ABLCIAImporter-shaped data (first sheet only for xlsx).""" + path = Path(path) + if path.suffix.lower() in {".xlsx", ".xls"}: + cfs = pd.read_excel(path, sheet_name=0) + else: + cfs = pd.read_csv(path, comment="#") + if "name" not in cfs.columns or "amount" not in cfs.columns: + raise ValueError("bw2io LCIA file must include 'name' and 'amount' columns") + + exchanges = [] + for _, row in cfs.iterrows(): + if pd.isna(row.get("amount")): + continue + exchanges.append( + { + "name": str(row["name"]), + "categories": split_tuple_path(cell_str(row.get("categories"))), + "amount": float(row["amount"]), + **uncertainty_from_series(row), + } + ) + return [ + { + "name": tuple(name), + "unit": unit or "", + "description": description or "", + "filename": path.name, + "exchanges": exchanges, + } + ] diff --git a/activity_browser/bwutils/impact_categories/common.py b/activity_browser/bwutils/impact_categories/common.py new file mode 100644 index 000000000..213ae6f50 --- /dev/null +++ b/activity_browser/bwutils/impact_categories/common.py @@ -0,0 +1,140 @@ +"""Shared helpers for impact-category (LCIA) file interchange.""" +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any, Mapping, Sequence + +import bw2data as bd +import pandas as pd + +from activity_browser.bwutils.uncertainty import UNCERTAINTY_FIELDS + + +class ConflictMode(str, Enum): + SKIP = "skip" + OVERWRITE = "overwrite" + RENAME_PREFIX = "rename_prefix" + + +@dataclass +class ImportStats: + written: int = 0 + skipped: int = 0 + unlinked: int = 0 + + +class CancelledError(Exception): + """Raised when the user cancels a long-running LCIA file job.""" + + +def raise_if_cancelled(check) -> None: + if check and check(): + raise CancelledError() + + +def join_tuple_path(parts: Sequence[Any]) -> str: + return "::".join(str(p) for p in parts) + + +def split_tuple_path(value: str | None) -> tuple[str, ...]: + if value is None or (isinstance(value, float) and pd.isna(value)): + return () + text = str(value).strip() + return tuple(p for p in text.split("::") if p) if text else () + + +def cell_str(value: Any) -> str: + """Coerce a spreadsheet cell to ``str``; blank for missing/NaN.""" + if value is None or (isinstance(value, float) and pd.isna(value)): + return "" + return str(value) + + +def apply_name_conflicts( + data: list[dict], + existing: set[tuple], + *, + mode: ConflictMode, + prefix: str | None = None, + renames: dict[tuple, tuple] | None = None, +) -> list[dict]: + """Return a new method list after applying conflict policy (pure transform).""" + renames = renames or {} + result: list[dict] = [] + for ds in data: + original = tuple(ds["name"]) + name = tuple(renames.get(original, original)) + if name != original: + ds = {**ds, "name": name} + if name not in existing: + result.append(ds) + elif mode == ConflictMode.SKIP: + continue + elif mode == ConflictMode.OVERWRITE: + result.append(ds) + elif mode == ConflictMode.RENAME_PREFIX: + if not prefix: + raise ValueError("prefix is required for RENAME_PREFIX") + result.append({**ds, "name": (prefix, *name)}) + else: + raise ValueError(f"Unknown conflict mode: {mode}") + return result + + +def cf_amount_and_uncertainty(cf_data: Any) -> dict[str, Any]: + if not isinstance(cf_data, dict): + return {"amount": float(cf_data)} + row = {"amount": float(cf_data.get("amount", 0))} + for field in UNCERTAINTY_FIELDS: + if field in cf_data and cf_data[field] is not None: + row[field] = cf_data[field] + return row + + +def uncertainty_from_series(row: Mapping[str, Any]) -> dict[str, Any]: + """Typed uncertainty fields from a spreadsheet row (skip missing/NaN).""" + out: dict[str, Any] = {} + for field in UNCERTAINTY_FIELDS: + if field not in row: + continue + val = row[field] + if val is None or (isinstance(val, float) and pd.isna(val)): + continue + if field == "uncertainty type": + out[field] = int(val) + elif field == "negative": + out[field] = bool(val) + else: + out[field] = float(val) + return out + + +def activity_for_cf_key(key: Any): + try: + return bd.get_activity(key) + except Exception: + return bd.get_node(id=key) + + +def unlinked_exchanges(data: list[dict]) -> list[dict]: + return [ + {"method": ds["name"], **exc} + for ds in data + for exc in ds.get("exchanges", []) + if "input" not in exc + ] + + +def exchange_link_counts(data: list[dict]) -> tuple[int, int]: + """Return ``(linked_cf_count, unlinked_cf_count)`` after strategies.""" + total = sum(len(ds.get("exchanges", [])) for ds in data) + unlinked = len(unlinked_exchanges(data)) + return total - unlinked, unlinked + + +def drop_unlinked_exchanges(data: list[dict]) -> list[dict]: + return [ + {**ds, "exchanges": [e for e in ds.get("exchanges", []) if "input" in e]} + for ds in data + ] diff --git a/activity_browser/bwutils/impact_categories/ecoinvent_lcia.py b/activity_browser/bwutils/impact_categories/ecoinvent_lcia.py new file mode 100644 index 000000000..07fe41307 --- /dev/null +++ b/activity_browser/bwutils/impact_categories/ecoinvent_lcia.py @@ -0,0 +1,154 @@ +"""Ecoinvent LCIA Implementation Excel import (vendor multi-method workbook).""" +from __future__ import annotations + +import functools +import warnings +from numbers import Number + +import tqdm +from bw2data import Database, Method, config, methods +from bw2io.importers.base_lcia import LCIAImporter +from bw2io.strategies import ( + drop_unspecified_subcategories, + link_iterable_by_fields, + normalize_units, + rationalize_method_names, + set_biosphere_type, +) +from openpyxl import load_workbook + + +class EcoinventLCIAImporter(LCIAImporter): + """Import ecoinvent-compatible LCIA Implementation Excel workbooks.""" + + def __init__(self, filepath, biosphere=None): + self.strategies = [] + self.applied_strategies = [] + self.filepath = filepath + self.biosphere_name = biosphere + if self.biosphere_name: + self.set_biosphere(self.biosphere_name) + + @classmethod + def setup_with_ei_excel(cls, file: str, biosphere_database: str | None = None): + importer = cls(file, biosphere_database) + importer.set_biosphere(biosphere_database or config.biosphere) + importer.cf_data, importer.units = convert_lcia_methods_data(file) + importer.separate_methods() + return importer + + def set_biosphere(self, biosphere_database: str, *, relink: bool = False): + kwargs = {"other": Database(biosphere_database), "fields": ("name", "categories")} + if relink: + kwargs["relink"] = True + self.strategies = [ + normalize_units, + set_biosphere_type, + drop_unspecified_subcategories, + functools.partial(link_iterable_by_fields, **kwargs), + ] + + def add_rationalize_method_names_strategy(self): + self.strategies.append(rationalize_method_names) + + def separate_methods(self): + """Split flat CF rows into distinct method datasets.""" + missing = {line["method"] for line in self.cf_data if line["method"] not in self.units} + if missing: + warnings.warn( + "Missing units for following: " + + " | ".join(sorted(str(m) for m in missing)) + ) + + by_method: dict[tuple, dict] = {} + for line in self.cf_data: + if line is None: + continue + assert isinstance(line["amount"], Number) + name = line["method"] + if name not in by_method: + by_method[name] = { + "filename": self.filepath, + "unit": self.units.get(name, ""), + "name": name, + "description": "", + "exchanges": [], + } + by_method[name]["exchanges"].append( + { + "name": line["name"], + "categories": line["categories"], + "amount": line["amount"], + } + ) + self.data = list(by_method.values()) + + def apply_strategies(self, strategies=None, verbose=False): + for strategy in tqdm.tqdm( + strategies or self.strategies, desc="Applying strategies" + ): + self.apply_strategy(strategy, verbose=verbose) + + def prepend_methods(self, prepend: str): + if not prepend: + return + for method in tqdm.tqdm(self.data, desc=f"Prepending {prepend} to ICs"): + method["name"] = (prepend, *method["name"]) + + def write_methods(self, overwrite=False, verbose=True): + num_methods, num_cfs, num_unlinked = self.statistics(False) + if num_unlinked: + raise ValueError(f"Can't write unlinked methods ({num_unlinked} unlinked cfs)") + for ds in tqdm.tqdm(self.data, desc="Writing impact categories"): + name = ds["name"] + if name in methods: + if not overwrite: + raise ValueError( + f"Method {name} already exists. Use overwrite=True" + ) + del methods[name] + method = Method(name) + method.register( + description=ds["description"], + filename=ds["filename"], + unit=ds["unit"], + ) + method.write(self._reformat_cfs(ds["exchanges"])) + if verbose: + print( + f"Wrote {num_methods} LCIA methods with {num_cfs} characterization factors" + ) + + +def convert_lcia_methods_data(filename: str): + wb = load_workbook(filename, read_only=True) + + cf_data = [] + sheet = wb["CFs"] + for rowidx, row in tqdm.tqdm( + enumerate(sheet.rows), total=sheet.max_row, desc="Processing CFs" + ): + if not rowidx: + continue + data = [cell.value for _, cell in zip(range(8), row)] + if isinstance(data[-1], Number): + cf_data.append( + { + "method": tuple(data[:3]), + "name": data[3], + "categories": tuple(data[4:6]), + "amount": data[6], + } + ) + + units = {} + sheet = wb["Indicators"] + for rowidx, row in tqdm.tqdm( + enumerate(sheet.rows), total=sheet.max_row, desc="Processing indicators" + ): + if not rowidx: + continue + data = [cell.value for _, cell in zip(range(4), row)] + units[tuple(data[:3])] = data[3] + + return cf_data, units diff --git a/activity_browser/bwutils/impact_categories/templates.py b/activity_browser/bwutils/impact_categories/templates.py new file mode 100644 index 000000000..ffcfef8bd --- /dev/null +++ b/activity_browser/bwutils/impact_categories/templates.py @@ -0,0 +1,86 @@ +"""Resolve and copy impact-category interchange templates.""" +from __future__ import annotations + +import shutil +from pathlib import Path + +TEMPLATES_DIR = Path(__file__).resolve().parents[2] / "templates" / "impact-categories" + +# kind -> files relative to TEMPLATES_DIR (csv kinds are pairs) +TEMPLATE_FILES = { + "ab-xlsx": ("ab-lcia.xlsx",), + "ab-csv": ("ab-lcia.cfs.csv", "ab-lcia.metadata.csv"), + "bw2io-xlsx": ("bw2io-lcia.xlsx",), + "bw2io-csv": ("bw2io-lcia.csv", "bw2io-lcia.metadata.csv"), +} + +TEMPLATE_LABELS = { + "ab-xlsx": "AB impact-category file (Excel) — multi–IC; recommended", + "ab-csv": "AB impact-category file (CSV pair) — multi–IC", + "bw2io-xlsx": "bw2io impact-category file (Excel) — one IC per file", + "bw2io-csv": "bw2io impact-category file (CSV) — one IC per file + metadata sidecar", +} + + +def template_paths(kind: str) -> list[Path]: + if kind not in TEMPLATE_FILES: + raise ValueError(f"Unknown impact-category template kind: {kind}") + paths = [TEMPLATES_DIR / name for name in TEMPLATE_FILES[kind]] + missing = [p for p in paths if not p.is_file()] + if missing: + raise FileNotFoundError(f"Template file(s) not found: {missing}") + return paths + + +def copy_impact_category_template(kind: str, destination: Path) -> list[Path]: + """ + Copy template file(s) for ``kind`` to ``destination``. + + For single-file kinds, ``destination`` is the target file path. + For CSV pairs, ``destination`` is a directory or a base stem path; both + siblings are written beside/into it. + """ + sources = template_paths(kind) + destination = Path(destination) + written: list[Path] = [] + + if len(sources) == 1: + dest = destination + if dest.is_dir(): + dest = dest / sources[0].name + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(sources[0], dest) + written.append(dest) + return written + + # CSV pair + if destination.suffix: + # treat as stem path (possibly with .csv) + directory = destination.parent + stem = destination.name + for suffix in (".cfs.csv", ".metadata.csv", ".impact-categories.csv", ".csv"): + if stem.lower().endswith(suffix): + stem = stem[: -len(suffix)] + break + else: + stem = destination.stem + else: + directory = destination if destination.suffix == "" else destination.parent + if destination.exists() and destination.is_dir(): + directory = destination + stem = sources[0].name.split(".")[0] + else: + directory = destination.parent + stem = destination.name or sources[0].name.split(".")[0] + + directory.mkdir(parents=True, exist_ok=True) + for source in sources: + # preserve meaningful suffixes after the shared stem prefix of the packaged name + # e.g. ab-lcia.cfs.csv -> {stem}.cfs.csv + name = source.name + packaged_stem = name.split(".")[0] + remainder = name[len(packaged_stem) :] # includes leading dots/suffixes + dest = directory / f"{stem}{remainder}" + shutil.copy2(source, dest) + written.append(dest) + return written diff --git a/activity_browser/bwutils/io/ecoinvent_lcia_importer.py b/activity_browser/bwutils/io/ecoinvent_lcia_importer.py deleted file mode 100644 index a067fbba8..000000000 --- a/activity_browser/bwutils/io/ecoinvent_lcia_importer.py +++ /dev/null @@ -1,183 +0,0 @@ -import functools -import warnings -import tqdm -from numbers import Number - -from bw2data import Database, config, methods, Method -from openpyxl import load_workbook - -from bw2io.strategies import ( - drop_unspecified_subcategories, - link_iterable_by_fields, - normalize_units, - rationalize_method_names, - set_biosphere_type, -) -from bw2io.importers.base_lcia import LCIAImporter - - -class EcoinventLCIAImporter(LCIAImporter): - """ - A class for importing ecoinvent-compatible LCIA methods - - """ - def __init__(self, filepath, biosphere=None): - self.strategies = [] - self.applied_strategies = [] - self.filepath = filepath - self.biosphere_name = biosphere - - if self.biosphere_name: - self.set_biosphere(self.biosphere_name) - - @classmethod - def setup_with_ei_excel(cls, file: str, biosphere_database: str | None = None): - """Initialize an instance of EcoinventLCIAImporter. - - Defines strategies in ``__init__`` because ``config.biosphere`` is dynamic. - """ - importer = cls(file, biosphere_database) - importer.strategies = [ - normalize_units, - set_biosphere_type, - drop_unspecified_subcategories, - functools.partial( - link_iterable_by_fields, - other=Database(biosphere_database or config.biosphere), - fields=("name", "categories"), - ), - ] - importer.cf_data, importer.units = convert_lcia_methods_data(file) - importer.separate_methods() - return importer - - def set_biosphere(self, biosphere_database: str): - self.strategies = [ - normalize_units, - set_biosphere_type, - drop_unspecified_subcategories, - functools.partial( - link_iterable_by_fields, - other=Database(biosphere_database), - fields=("name", "categories"), - relink=True, - ), - ] - - def add_rationalize_method_names_strategy(self): - self.strategies.append(rationalize_method_names) - - def separate_methods(self): - """Separate the list of CFs into distinct methods""" - methods = {obj["method"] for obj in self.cf_data} - - self.data = {} - - missing = set() - - for line in self.cf_data: - if line["method"] not in self.units: - missing.add(line["method"]) - - if missing: - _ = lambda x: sorted([str(y) for y in x]) - warnings.warn("Missing units for following:" + " | ".join(_(missing))) - - for line in self.cf_data: - assert isinstance(line["amount"], Number) - - if line["method"] not in self.data: - self.data[line["method"]] = { - "filename": self.filepath, - "unit": self.units.get(line["method"], ""), - "name": line["method"], - "description": "", - "exchanges": [], - } - - self.data[line["method"]]["exchanges"].append( - { - "name": line["name"], - "categories": line["categories"], - "amount": line["amount"], - } - ) - - self.data = list(self.data.values()) - - def apply_strategies(self, strategies=None, verbose=False): - strategies = strategies or self.strategies - for strategy in tqdm.tqdm(strategies, desc="Applying strategies", total=len(strategies)): - self.apply_strategy(strategy, verbose=verbose) - - def prepend_methods(self, prepend: str): - if not prepend: - return - for method in tqdm.tqdm(self.data, desc=f"Prepending {prepend} to ICs"): - method["name"] = tuple([prepend, *method["name"]]) - - def write_methods(self, overwrite=False, verbose=True): - num_methods, num_cfs, num_unlinked = self.statistics(False) - if num_unlinked: - raise ValueError( - ("Can't write unlinked methods ({} unlinked cfs)").format(num_unlinked) - ) - for ds in tqdm.tqdm(self.data, total=len(self.data), desc="Processing CF's"): - if ds["name"] in methods: - if overwrite: - del methods[ds["name"]] - else: - raise ValueError( - ( - "Method {} already exists. Use " - "``overwrite=True`` to overwrite existing methods" - ).format(ds["name"]) - ) - method = Method(ds["name"]) - method.register( - description=ds["description"], - filename=ds["filename"], - unit=ds["unit"], - ) - method.write(self._reformat_cfs(ds["exchanges"])) - if verbose: - print( - "Wrote {} LCIA methods with {} characterization factors".format( - num_methods, num_cfs - ) - ) - - -def convert_lcia_methods_data(filename: str): - sheet = load_workbook(filename, read_only=True)["CFs"] - - def process_row(row): - data = [cell.value for i, cell in zip(range(8), row)] - if not isinstance(data[-1], Number): - return None - else: - return { - "method": tuple(data[:3]), - "name": data[3], - "categories": tuple(data[4:6]), - "amount": data[6], - } - - cf_data = [] - for rowidx, row in tqdm.tqdm(enumerate(sheet.rows), total=sheet.max_row, desc="Processing CF's"): - if rowidx: - cf_data.append(process_row(row)) - - sheet = load_workbook(filename, read_only=True)["Indicators"] - - def process_unit_row(row): - data = [cell.value for i, cell in zip(range(4), row)] - return tuple(data[:3]), data[3] - - units = {} - for rowidx, row in tqdm.tqdm(enumerate(sheet.rows), total=sheet.max_row, desc="Processing indicators"): - if rowidx: - key, value = process_unit_row(row) - units[key] = value - - return cf_data, units diff --git a/activity_browser/bwutils/uncertainty.py b/activity_browser/bwutils/uncertainty.py index 9af92b245..794891a72 100644 --- a/activity_browser/bwutils/uncertainty.py +++ b/activity_browser/bwutils/uncertainty.py @@ -37,6 +37,9 @@ "negative": False, } +# Ordered field names for spreadsheet columns / CF serialization (keys of EMPTY_UNCERTAINTY). +UNCERTAINTY_FIELDS = tuple(EMPTY_UNCERTAINTY) + # Fields that may be left empty; ``stats_arrays`` supplies defaults or ignores them. OPTIONAL_UNCERTAINTY_FIELDS = { sa.BetaUncertainty.id: frozenset({"minimum", "maximum"}), diff --git a/activity_browser/templates/README.md b/activity_browser/templates/README.md index d07e987fc..c74352487 100644 --- a/activity_browser/templates/README.md +++ b/activity_browser/templates/README.md @@ -7,8 +7,9 @@ Bundled spreadsheet templates shipped with Activity Browser (top-level package f - **`scenarios/`** — scenario difference / parameter-scenario workbooks for calculation-setup Scenario mode - `flow-scenarios.xlsx` / `.csv` — empty flow scenario (SDF) headers; xlsx includes a `README` sheet - `parameter-scenarios.xlsx` / `.csv` — empty parameter scenario headers; xlsx includes a `README` sheet - -Future additions may include Brightway Excel database examples under additional subfolders (e.g. `databases/`). +- **`impact-categories/`** — LCIA / impact-category interchange starters + - `ab-lcia.xlsx` / `ab-lcia.cfs.csv` + `ab-lcia.metadata.csv` — **AB impact-category file** (multi–impact-category; recommended default) + - `bw2io-lcia.xlsx` / `bw2io-lcia.csv` + `bw2io-lcia.metadata.csv` — **bw2io impact-category file** (one impact category per CF file) ## Usage @@ -22,8 +23,8 @@ templates = Path(activity_browser.__file__).resolve().parent / "templates" 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). +Excel workbooks: **data sheet first**, then **`README`** (and metadata sheets where applicable). +CSV files: header row, then notes on lines starting with `#` (ignored on import). Scenario import comments (Excel and CSV): @@ -32,7 +33,10 @@ Scenario import comments (Excel and CSV): **Get template → flow-scenarios** always copies the empty starter file (does not generate from project parameters). +Impact categories: **Impact categories → Get template…** in the application menu (AB impact-category file is the default choice). + ## Maintenance - Keep column headers aligned with `SUPERSTRUCTURE` / parameter-scenario import expectations in `bwutils/superstructure`. +- Keep impact-category templates aligned with `bwutils.impact_categories`. - When adding new `.xlsx` / `.csv` templates, include them in `MANIFEST.in` so they ship in wheels/sdists. diff --git a/activity_browser/templates/impact-categories/ab-lcia.cfs.csv b/activity_browser/templates/impact-categories/ab-lcia.cfs.csv new file mode 100644 index 000000000..e8caeffe4 --- /dev/null +++ b/activity_browser/templates/impact-categories/ab-lcia.cfs.csv @@ -0,0 +1,3 @@ +method,flow,amount,uncertainty type,loc,scale,shape,minimum,maximum,negative +# method uses :: for Brightway name parts; flow is name::category::... +# Optional uncertainty columns: uncertainty type, loc, scale, shape, minimum, maximum, negative diff --git a/activity_browser/templates/impact-categories/ab-lcia.metadata.csv b/activity_browser/templates/impact-categories/ab-lcia.metadata.csv new file mode 100644 index 000000000..2176ce964 --- /dev/null +++ b/activity_browser/templates/impact-categories/ab-lcia.metadata.csv @@ -0,0 +1,2 @@ +method,unit,description +# One row per impact category; method must match CFs.method values diff --git a/activity_browser/templates/impact-categories/ab-lcia.xlsx b/activity_browser/templates/impact-categories/ab-lcia.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7810b409e88f7bf46f593796e3de2ec544fd528b GIT binary patch literal 6374 zcmZ`-1yoe&-yT4^QyEISM!LHO2^m7V5s;7^y2Bx)yCkFyO6i87Vd<1k0g;vt|D${M zbC+-To^$V{KDd3kzprKadFB?kI)BXZY$%D5MTqCNug3uvfOI`ZtN14FoRg#K8qt=OL6 zkhgLhPLc8WB%;pLv8u}_@{yV5!#pJx!KOE(L?p4Ya4I|MkI1J3q))TI^gCW#pthnn z+*kX@7UK3P821oMXGaDA2>-qXb4RG<&piw#v@3OT5rmq6p^nVEV|2N5k*kW*PSS^D z37;2C$6*!)3$=pLjYe`bZRXE!eUEXU6gR1HEW0>SqmV}lR#J7dBrScSkG~I4ryA|o zE$U3m@VQ@7@Er&JK*O&WMP|?on@rJGk%-K6MTc%jv=oNL(ig&O{DE{|vLnt+9|_Oe zxd1~;4{0a$)h!=Bl_3xYnTkyJC0%ZaMp9zc|N(|T%xPi3R3uf9qa)loH;(`x}9 z6~F$L>n1{eq;D<7h^f_KZ_fk2q}*$P?7HUdd6});IM!klXPM-68GOp7Qt-#jIo*uM z!4AhI=RXf8-7u4$01W`hWCj3;5r^Yx$LVTqX>a-Gll$lK?CKl1%nFhC@0X8wTAkWs zcMAm7R1v$_xX)Fk!*(GwA%wcoc3u|ouz=Rb0t5poxVs4|4X?%Sfwr%Pz>hb#guIUT zIG$~Vh9@0)nttY-n`*4(TwK=SVIY*|A}=mKXu~ReVqC3n1LzR)>mw%Wm=BJ`Q1&Dl zm@-^80CLh#eXW{X(1zLA uIbfUbstlpRn&-!lHS|E*Cm2Wd* zF>^Xw7GLx}`l;&pncvdRYwJv`Xam*p7aj}aF|WEyM?7&`4pqZC%3gJU7;)XPVPd{1 zdr`1WW-TMXw&P7iTD>7P{t%$+ !n)~@H188g0$g*?w;@~!rShyds8-J z BcnIy <||yS7TY=_Gjua8B=|hIK-rYDHWg zVo8jw&@@{$O5_4e?v&in^Q!I=!ewH=+7^u7rReap4~`k)I=Q-vv#@gldEjcM%NLhX zyDRiU`O#CYruqC-T~egf2TOeK`9WP%p2=;8rI}(>#6*Kh*l5zBsSHd+?}YtT6o_xx z3ukXL$E6OEI2kUL;$v+p>5E;N2Df;D?egUmZeiAAV&1o?7n%H6l<^c)$W|1U^0Y9U z#slhdhM0^}I3c@?#4~aMAhen+sK)sn%v7JKd(;y$Rz-NNSHhntVcPp`Fy3uwDjtt8 z!B`OS?NDSKVJmg0kW$8Op0n<~n{49_#rCb>f#)}M{-Kk+cA%KE#}oIJ*72E&CAWF_ z(DO6&Fq>i8wmI*^_$1y^^I)MK)>BJtcza?ArgS%{l?D4C{TQ7Ugnu{>ZW9jR@Eb1N zum3KdJ60=5?`PFM9%-;B?_kh*RmMF#&BwuFt^*zH?a?exWStStY5R6#Es=e=d-<6G zR-U2vh@LD?IjB;aq3$}e|A4I%bIZbj5pzIu+)^-tC|icYVf*QcQu6dhc|A*=n8EsX zd9JGjX!t_RWV31zn^(3Ls{FMksim4pNa_2>Kb#=c1302qVhL0=9&f1luTG8J*Xawn zo~Fvy0cBcH>>IM?MTD}`;_I=-HR2pQF1EWofvduw@74RI>DX`1jE-JywyRg~A2S z^@Jbg(eCAjrVV$ SPnOf@uF8%x?R zADO4VQTUy=CAU@4(b6LvOprmi9?`0b(eC8t>&!B5I`_*|k9O0e8>ha<_#2{|Cf|)@ z%luCl)-D|qMHQZ8dUX*Zxq^}DlPS^2?(?jx?Ipya5hD#I^~oI|2U`?T2BuwNHy`2D zvyu*#N(cvlK`%og#$${`BoP%jsI>Q?Yy10&wjNQ_m!Kefm?n?K HM@ zmV}+=L>5tuzYv3D3;f&tA|e6_KRjz`C|A8;%oMHiIShLE`g&@v;B;Xb$9Lj`exU_B zp` >SdP+@QX7(U z6S&r=xO|lHsK(ssH3IqT5G=9z+kM;$cxTMYZe%OcKNHA6>sDhL5&%GQ4*+=ZX9DqX zguZaGwzPD0;r#XUD~p6A**nfZ#OZ0pT183PDR6(9ET><-JFAokDXnQfWrPG{387eR z`&}WOy@B*Y%b!vB2h5pjJXv3oiS1oo0Nz*n%!@Cm6w>e*dnFjn)wz(-F(`jWloTy? z#G)5?VpH9qO;(kt6>m;qLdy4P6i?6?HmZNnXu!vpe565*!r8DRjH;=HGqS>d6l=DY zUkqW3uGtD^Viv0@y!Q4w&OF=1mE?!4ynLj=Y>-#GF2-MISQtEFI;e=Wx`|&ln#Mpf zaPBvfRGfr)ZA~*t1Kt0a`skiv;zc3Fp7I4a0?pt7ZtKYR@G$=}2 `5>MRFz zJ78>|H?{sR{JO1D^liByd5R!DVISSH0U;XnMaHN-I!JIL$|^Slr`yojLktp4k_WIK zzS5XVsC1lb$A9#FNk;M{SN*0s+IR*fz*Ews`BcPVbq0EuOZYO6DrVT}v@!!#0*eUz z-1&*!fu4mH#_FCYhU6oay0Pq%eWlhIEZhmREaxK`nGhYT`7a_4TnV?*$ KA*=~Arf#&Z_=9NZbmGiYAVN`Hs~YsQZcL|b^uQSCK8$zz-HPJxGxya$GGzC zc#Ylvj$f6>?kx5rtPq9%pO)bLX^D_zd&gNWf}YlX*2E%69&C*HD-uzK!-uN(Zx^Pf z9iWO$m$a2x8mHSHcya2lEXtw&=Sw$3hgm)B<~c?|TPs?MyG-gUetoMmjHqmTmql_a z?CBpPe1NlJ?uFp&DLTfr6;mfdb`DoQs%aNxQL5Md%DK)q?OICCjoJBp9~51QcA56T z#^+pwsy&Ruhe$WEP#YT(L1T?vHBmD@-9MN{zrhB};kVu$szL0vR 1n`UBNh1~9(G0Pj4i#M z)S3U{vIy{B%~tK1DrpN9D_&!G;P&nEp>^%ZN)mNbp_cT@_4h_rFi(uQGwhYn=l(AX zpLV7-po6kSj$Pbcjt@g|J1e?|nI>fq=SV7@%HZW@5NS$%HP?r$x^EL8*Fr1QF6Pq* z_RhR7pD4iccXNYAsO>eJvev@<(NAn-{bu16=ie@|{LIE=*X`fe u)*bxbyv ~0k!;K9BsRe!FEZ!w`vK?0jflXKPcIiLCb@34J!7w9Wew;`jpd^FFA zS;IQEchEWI5*4q;504r?XsB69&ya|jn^31Rzm!Iw*6MIOp((5JwjCc#;?Z~NuB(XB znoUj%e`4w#&a}9*v69uWA#i_IU9h+J;!S`ezo}qz0PjIj$<_D7!^O9=psjj23V{*t z=TS?z@bPi6w(-_~Mxy0Cwa4!e`X|E%0C4}zD=w~Hc9t$bMZcgu7cs|0a6`I+|28o# zqpmYZfPkgS((Ej^TnW^JtOvCeX1F@Zil^NClu@{APAot@IGWCzLB3*ivRBgSLJu=9 z*4}P;0^Dp+wVB FZtBF& z5pB2MBBfwQ%l`Xhr9v=D&5?Emy^iIl6{WqB?>=7alfrfPw`M3TK`wC-EQ!M2m(zgZ z4y;PbwJHsY$RR!!*wRAX4cb90wSYq59xqCd_oVYCY|=p2GsdGOXvVrVQ&eHs^unv< zJk1dE7Ku*OoZW2SBKSqKMxMJ=#nH&}csd7oM0h+{UD2N_ QhGqQNfHxrUoZ ze>@_caKbuUCJ-=tY?d{jj}BZPaCzSYRM|M}YGA)!xAgStUq@e5bknkIlxAZ-sk61v z!n`TEC0IP9GQ4a&uV^LWr%CcjZ{w+-T>>+P)ug$Ln=y79>0D6^EsNzia9ArEy-Iv5 zur@~RzGz_@`1Sno_$tuuhB-zpGq8)WlZA>6C4yKtY3PS*HltT|pnCnI{N99hkRrsi zD=zwnhKMr+Dd$` xCK>Lh~KCqDfFgmO hX4V;p|)h7x_U0nl@N z MH zpU&GaCpu_^C?jN1008_yo%d%^_{`Dq#ZULGPMEWWOCbg>jNefB)D3cJkJZyxyoE#OV$P+{Qc<}l7& m3?EqK)vlnWt4tWV z()T4+>?I=I0=0ZN_ByMw?z)nTKQ|etybp&59LV}qz?#CRg1U>wYg)?x=nvGcW4i%N z2Fo=V4tCX07)5&y@fzk9)L?S4M9sSbdvE1)MuUu>wVicDouGVguIL;A^Qk0vMxGQB zHoYzCY^|_<9PS%;M(r4I2D`#%-;E5LUjMAxL@$}I8y3t8xQ#<7^Se@0@AKm5qyr>J zWTj|cc%r`WG$`3UvN^2z_A#ogA_{)x7Mmg)1U&LMaxNH0eia>^K`$rX6!1W7EN?<% zNbzc P{KaX!rG71HuA8ge8D~TH;UD{oNkF6xfw0jQ5g@09+pI3kYRY zBt+5z&W2;_a)F+G+(>6}x*}j{(}>^;@4qy^xY3EqXLe7Z;8$C)hIY45u#oXrwxFmP zn{)PQ0mCkeG>cNj9zP9A@nUC(wH-T{p5nyI`4lCp0DsWF|6~XCeS1B*)+X6@pj}i5 zj1Ngvd`jDc-0eU?3<}#HvREkqgP!Q0BM*^bfpV99b4Q4<@JZ;hMG6L=4{G0Rw0UPQ ztxmOcx> 5f>RJ8% zY>`EIV&8P;iZm;H^Jl|sE~NSV$#gLTS<@ygT|p1)puO$ga+%ID9K%GHy=Q^43j=r> zJit%YO5aN;t@?(P3Yt+##)+)Faop_LMsjCGI0zpQvqa+pgxTdsKl7z~!nyn%%f8_A zJe3+UEGCGjQg#yFvsN@Qk|PV2dwv (6V6 zw;_I@TSc1MfSDcxj9o{QdVf*~dBFO>ijjdI!k>|lNdW&n2Syz1uPYF7(*J)dd>4Ip z1p6lz00>0t_&fTqf$UxQ-67>~_%h<}|7B!(m*8%b`WHb>5G}?pf`4sU@3P!&j()T3 zWBx14uU6?U%iY5MH;We{Cm=R+cP+ol{JSi7%cH+o0+GD%ezE*6mhM9DW})9u6~uV_ zZ#n5M@NQK94HO{yzm@+L {A{Su$bP>44+_l;&;S4c literal 0 HcmV?d00001 diff --git a/activity_browser/templates/impact-categories/bw2io-lcia.csv b/activity_browser/templates/impact-categories/bw2io-lcia.csv new file mode 100644 index 000000000..26dd3a0cc --- /dev/null +++ b/activity_browser/templates/impact-categories/bw2io-lcia.csv @@ -0,0 +1,2 @@ +name,categories,amount,uncertainty type,loc,scale,shape,minimum,maximum,negative +# bw2io one-shot CF table; categories use :: diff --git a/activity_browser/templates/impact-categories/bw2io-lcia.metadata.csv b/activity_browser/templates/impact-categories/bw2io-lcia.metadata.csv new file mode 100644 index 000000000..f4d33e204 --- /dev/null +++ b/activity_browser/templates/impact-categories/bw2io-lcia.metadata.csv @@ -0,0 +1,3 @@ +filename,method,unit,description +# Shared metadata sidecar for a batch of bw2io CSV CF files +# filename matches the sibling CF CSV name; method uses :: for Brightway name parts diff --git a/activity_browser/templates/impact-categories/bw2io-lcia.xlsx b/activity_browser/templates/impact-categories/bw2io-lcia.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6786b9d089d458aa2f1c619fa42d9343aa22a5e9 GIT binary patch literal 6236 zcmZ`-1yqz<*B)AiZWvNp80jtn1qr1)Bu5w;hL#*s5T#2(qy$N&LAnu!2I&+Kk&>=& z@cwss<-d2eRvui;g(eO6>XZn&9Q+585g82#F1L`XJNCh++gs@O9K5`` zso!!vXJoBPmfPi8vFGRO!jm3nI+g0{&5Focc~-(Z8~CCeM36&Iht~Giej*@*7gyx- zQgzwJD3`pI8zNEG zt?n0MS2>VBzQjxjsXGY>P@84{oRl#_F6Hv=aX<7X=|( zbaXQM5;@Bp`A~(r`RI==Xy16?0*XlI<^IXXl__*+UASsF4rPy79mr>d71|m~8xBWT z_!{se-{(<<1#CjD6zbC{iEcl4K$o@9fch$@m=%wN2L~snU Qt$}i*-)kfPW8+|7tJ#4I z>Sd)wVT~QfPAeJhw$ggF%s-u!VwA>2hzS6su>k-iNGEyPal2YW>>+ 6bh`WAb~;Mrz=u|w^Zmui1ecDyewma{hJ;N5%wkGZN)yWdo6(u z-Z&eOd$_hP; # zD+-d0LZU0fS{q)mcVgFZJ~bx=L2QW8@&!8ZQ0p=H4>;zps2GGVYPGgE+gRPxvF#cf zRtSLi_{L+@DIR8zklo_L6BkR@8*ukIY~RX!(L3cTU=LbY-t`oz>Kl%9qCsC!ug_>r z|7q8hBZFO$4IQ+cJe(?t$t#S~R2@0;o!@+Iod%3DR2{MPm>qffyt8=F6R&YsHLR`V zdFT5<*G(uZ+eL|O&IY-)to-t(H!)e|s`Lm0K+nm2{PQqc;*&e4Zt {}QqcQLS!tFzk)O<>1sv;IzV~35R#}Rib)j+pWy8{m%!f$uX zlwKSp)%WD^d#C$~4cNx}L*Jw1Z`Hp>#GS*|S2FYCCLYTUI5mK=ch8s%&BE9m#7SJl zXsl#?5Qm2`xh#B$?Gxapii8!tPGXrc0 L?V@nczh)HZl#AN+o;i?=3w(CW`!PCbQTdUkoZ !AVdcsX9cnksWGCs7p(Ihx3ls{1JH|Z|ns d)Kx8yg6J4l?QtH-QXFc?bj3;f1E$i^Urx!JTp=11Z;Fm`a zM=6w62w3y)ZSV w#aIstHy7YH ^`j#h;RgPhAg$ihUS4% z!o+H6EqjfG;mSs7ma7yPaUx-|R?&~cf4|yA`CC (Xb$L^6_NYJnpf(T;5=8y%jYCDAYFwZ6%^_G^ Sfv65{a~3;d^gy|HukWMzq}z^Ll2IY?aWdCO~R$SHujKuQsij*aed=Seo8_ zgB4V&j=HCzZOUudiN+cI*5}a+xpk+tBqNjMq1ff=JV>QO=^YO$dqxe>R%kMb)%)H# zNO_AkEkfIHudLBVvVu$naj9S2orz1NLXJbMU=dG|r}TE8O0#i|Q?XrJq)Cpyia3d~ zVfky9vx;U7v3nH(Dqn5AXn{9`*D7ys{w^*y*f3n5c L5aKl|yAppur@k;9F7q5gUz3g3=$~^I!)R zZkb0DkbH{sX%Dyd4%tAll&HTPI4D%*$uJ8sX+#+=#%&6h)mD{`>((kfeO+Oixte}vT-_K52p!*mG zd+lA*Dpz!+H%@oLH!tcmEjEYt D5>mNfB-XS;z6ug}M) za}H-0aD7JK8{}GY65UhA@}ZB#w0L3oQ^4dEsd;f;d2#0yFRoXB-ac;DQ<3>Fxl|DU zSY(lC6wpy-OnUX+^eCS7A)WwD48{}Qlq#X@6&0Yw%;h#-S!?^t#jVIDls_BDemQ4f z3JL%~iH=- &t*p}_uZYu*nGkB^u(r&mb!fPiUAf> $a9b)xU}8cimepG{gTmSK)c)dR;KFB_#=C5)_= z)Hu|31ieu 0*3GDZg2}Z;^8==mpA(O3Ug&RC&~D)# zyJ7|(T?HVlFahEUai`n`OO&(^VfS=527IvQp%lR_LN>)*QYMOvN3a@? Aot%bhAUkfOfwZRZ`1%>h+|p z6nK;yR+Df%VKOnMJ2@rfjcyEmftD3D%CXPPaz03?hE0vH-f_=K2r0H` Uq-cyGsdAf+zUHZJ>jEj)I Y@MwK438W$H?feIOtKGTt>)s8$>!8Q{= zhocc^QMI!AusRVD{@{E7iaX{RIr@AB_%kHrJ|cTU$Z*hO{AWn`uS4P+Z)Q8qLl}D1 zyVx?x&<>q)=z!rWj(=#y%r&k?MEI7=v81!5Z?4Six)2KoiDVYVxq2X;jT=wJ`Z|*_ zw#joYKUMEAVsrk!2ds3mC>%PD6V$&u`MT uCu~XC*G=`D{K+WY BA#&fL`n3Tz6MXn8-6FFoEcuXdBv3@WAb*d*t7xi#`Bs4mkssIg znp1s`I*T->%(tltT9+2R*jd>q@^OvDW84)8ftP5six-x0psE@EX|zp+Go9A*I)Hho zC7=030yV3ER|2*0K6VJabH(~glg*sBxmJjHm(Li#3Es*?_uCx;^JJfHSR-I&r-Xob z$C-oUa5O3=qgFb}@JnD@7lH6d {4S6$qqo(gCGN-W%+z+@Rq!GO^o04aO6KIdN z55#jVyEnhwX2vdxHhcj4$q@_@Mx{xzARJ~@v@nXQ{prT7V%GU^i%h Em5p-BsU@@Y`LSs*bP&=09q&)BE&Nb>Sb*djISv4T z_g6KCxq8__VAlkj)tQb!66}I(k>Fiia%xR`pb#N@1;p$qt5gZxg{toY5oJC*NROde z`;eNuWkDiD*FTiPpGvi8eDI^F9mW)Fk*~AS_z1LCrwX0grXJe64B80B%n_u`*Z?;XajzvS+3BajvNqR51yZ`xxn0cMJJ??`-2~? z%X`duEqKgO&-0~6W8kE9Q<}KKmg$LC<8iVP_9Y6VxCN)#wk6#5lg3+j>9W1Sg^?64 zxk1qpxVoYrPwE*B*9$||{bEj%3F!@PGrDxb{UI`Rx=(TK*3w^}WsUOxIMr9(K-|92 z?3c}K(wQ_59cxH-`Z|Pb){443FOO61*vfTz2fVp5RKtyy_VDbWv_)jsQtqy*pKl?X zFUM(GIKJPU+qZN5o*)M~ku!Rn65aDwi>UHFvQa1C(E_26*+aARnQSc3N*}DS3-ow( zx1)~pd }P$E@{TnX{>ujJ+L5=>A+>JX#70xozU_yo%@`nX~4JR-Tkuwy9>6LYH0x-MD6Uf z9B2_Fdhr8St{E&|83F3GceA@=SHOxYrXA5yR~ll@Dkz!9ijj ~hmZ_2N;*<8xdX7jHO>knCO3s7d-d%D#&mj#EgFFA-$it-GDgzBHTX8ZF}%m`XeP zYIEUHhp2w2ajr|Y15XqwM#uuV(@FsG{M*WL)KOR+i@px2GkUovroQ}bPikmzF97x? z8oa;V5_Y8S?WLMKaTPpqQ=ue9)YCL0xlDrOHImi<6BmdbjGOEFnI2o~fb3$@{vCrE zJXnLb!}%e* j=X)da#)t@ZM^Ih9J+8)l2sU#i1-lJ@Qn!o{_h-x&E|WhtVS&r5218 z-iGTZ&emGMmopYDFPzOQE(PH|1mFE`GzzRX$#!jI@0LUZ00{m{-fJml?&xTHoxYW^ z(>A}x9W2Kvi2}1g&c|j&vpSPm71>8BMFMZ7&<&y0QWrE~WzBEC=jnd%quSj_ddg-& zP|F*(uB^Dlr$aP0BH}KG5m8UA8FZL1tk=<#?@nJog-TI^%D}QLpvYRj^zMt#_>sZ1 zXEG%mK4{Hd)r!Bk; RX!d+u{g~(!hVVqcs_g^usj_2#hm%Mcj*PxL zvZuZ$Cqc<{JlHkRUU2a$Di2&95ON2Ekt#-ix0_t89%$Re*iU8&W(W=RF?#MGvZsai z{yV8}OUZ+Udb&u|B{&KFU=D@#@aC{xB0*&JY5>G15JDp~QK~Hc9MoRFz1;9aHD7P~ z9bU`g&qD{-ZPlEZy&+rr(=n$Z$EK#46M{d>XT9CG?{!GegOCvb{S|?~*!Isj{9;~5 zoG5+}520Ks+y@ZKqDX|I4Vnta(c=M|m#?O $Yyhor5041 zwRY)jq-G}<{Md-5_QZm_M;jD&lBboIB=Jx)Fwu*XGq`!*!SoO}=E1YPxW}NY+l3$O zTzuZ0k1aRfYu?ex%LT=R#3^dt?n3Q!pe6x_Z4X#3=E#8`862YykORS43qDzc#6SX4 z#tgBX{-^yq7pu+Q8S_iyjqPsM+&u@IfN@;~sXtGgcdlf}gyh^jQvP4hj|}fSI=CWL ztcjMF69i^_&Aa}1#I;oHG|BzuX2TcSj#69?K!%#)R#d%w|Gkd6^*OrgS=K=L4guyG z0UJC2dtI5atD91&Pt!lr^)=j%IMOg53(#bjMMuPlG3ST-kI2hwSK6St@Mg~UaCoAs zVjO@@Y))=fP8sxOh|S59_@uCvC0h|#m?Jn~WZ8lVj4%7rCrsEo0vT#tx?4IQq&drS zA&5`Aj{@$`_Tg*rfj(3!{Vbxf>KRbV`Gi3_LTu%Y>t@d}m^CHFMMO!$9)$-G<&+=# zB9P+Q%H!u)@|A#3Q+mKCpD>12*-7+=wW5jf19JF-r>Eg;jLIgyQtPwhZ~VKz?ly*v z;`_a!N!+wL|FRr+8R84RRHU!=pX@TcwdH71>qn+S<-gLmXl&?<%wrT(Qow&tUy+Xe z^$9?F`v1>jZ(83ReEww%00f}4{nPsIG3ZV6n CKZ__^HT>T%t=%(S#YW~}>0y)k8zn%YG+ix1*EJVMJg^*c_H2#mWbQ9-h gdjG~@N3!)_l3h(11368;ZnQXnUSz}dA-jJ29|T?2-2eap literal 0 HcmV?d00001 diff --git a/activity_browser/ui/core/threading.py b/activity_browser/ui/core/threading.py index 7cea5ebad..f50238d2b 100644 --- a/activity_browser/ui/core/threading.py +++ b/activity_browser/ui/core/threading.py @@ -42,7 +42,10 @@ def run(self): raise e qt_tqdm.updated.disconnect(self._emit_status) - self.status.emit(100, "Complete") + if not self.isInterruptionRequested() and not getattr( + self, "_ab_cancel_requested", False + ): + self.status.emit(100, "Complete") def _emit_status(self, progress: int, message: str): if progress == 100: @@ -53,11 +56,26 @@ def _emit_status(self, progress: int, message: str): def run_safely(self, *args, **kwargs): raise NotImplementedError + def request_ab_cancel(self): + """Sticky cancel for long-running jobs (survives progress-dialog resets).""" + self._ab_cancel_requested = True + self.requestInterruption() + + def ab_cancel_requested(self) -> bool: + return bool( + getattr(self, "_ab_cancel_requested", False) + or self.isInterruptionRequested() + ) + def connect_progress_dialog(self, progress_dialog: QtWidgets.QProgressDialog): """ Connects the status signal to a progress dialog. """ def slot(progress, message): + if getattr(progress_dialog, "ab_cancelled", False) or ( + hasattr(progress_dialog, "wasCanceled") and progress_dialog.wasCanceled() + ): + return if progress == -1: progress_dialog.setLabelText(message) progress_dialog.setRange(0, 0) diff --git a/activity_browser/ui/dialogs/progress_dialog.py b/activity_browser/ui/dialogs/progress_dialog.py index 5dec1df83..ff0ea3169 100644 --- a/activity_browser/ui/dialogs/progress_dialog.py +++ b/activity_browser/ui/dialogs/progress_dialog.py @@ -7,20 +7,77 @@ class ABProgressDialog(QProgressDialog): @classmethod - def get_connected_dialog(cls, title: str) -> "ABProgressDialog": + def get_connected_dialog( + cls, title: str, *, cancellable: bool = False + ) -> "ABProgressDialog": from activity_browser.app import application - + dialog = cls(application.main_window) dialog.setWindowTitle(title) dialog.setLabelText("Initializing") + dialog.setRange(0, 100) dialog.setAutoReset(False) - dialog.setCancelButton(None) + dialog.setAutoClose(False) + dialog.setMinimumDuration(0) + dialog._ab_cancelled = False + dialog._updates_disconnected = False + if cancellable: + dialog.setCancelButtonText("Cancel") + else: + dialog.setCancelButton(None) - qt_tqdm.updated.connect(dialog._receive_update) - qt_pyprind.updated.connect(dialog._receive_update) + # qt_tqdm emits (percent: int, desc: str); qt_pyprind emits (title: str, percent) + qt_tqdm.updated.connect(dialog._receive_tqdm_update) + qt_pyprind.updated.connect(dialog._receive_pyprind_update) + dialog.canceled.connect(dialog._on_canceled) return dialog - def _receive_update(self, title: str, value: int): - self.setLabelText(title) - self.setValue(value) + def _on_canceled(self): + self.mark_cancelled() + + def mark_cancelled(self) -> None: + """Sticky cancel flag + stop progress updates (safe after dialog close).""" + self._ab_cancelled = True + self.disconnect_progress_updates() + + def disconnect_progress_updates(self): + """Idempotent: safe to call more than once (close() may re-enter via canceled).""" + if getattr(self, "_updates_disconnected", False): + return + self._updates_disconnected = True + for signal, slot in ( + (qt_tqdm.updated, self._receive_tqdm_update), + (qt_pyprind.updated, self._receive_pyprind_update), + ): + try: + signal.disconnect(slot) + except (RuntimeError, TypeError): + pass + + def detach(self): + """Disconnect all external slots before closing after a finished job.""" + try: + self.canceled.disconnect(self._on_canceled) + except (RuntimeError, TypeError): + pass + self.disconnect_progress_updates() + + @property + def ab_cancelled(self) -> bool: + return bool(getattr(self, "_ab_cancelled", False) or self.wasCanceled()) + + def _receive_tqdm_update(self, value: int, title: str): + # Calling setValue after Cancel can re-show / clear canceled state in Qt. + if self.ab_cancelled or getattr(self, "_updates_disconnected", False): + return + self.setRange(0, 100) + self.setLabelText(title or "Working...") + self.setValue(int(value)) + + def _receive_pyprind_update(self, title: str, value: float): + if self.ab_cancelled or getattr(self, "_updates_disconnected", False): + return + self.setRange(0, 100) + self.setLabelText(title or "Working...") + self.setValue(int(value)) diff --git a/docs/advanced-topics/impact-category-interchange.md b/docs/advanced-topics/impact-category-interchange.md new file mode 100644 index 000000000..5a6742571 --- /dev/null +++ b/docs/advanced-topics/impact-category-interchange.md @@ -0,0 +1,56 @@ +# Impact category import and export + +Activity Browser supports three spreadsheet interchange styles for **impact categories** (Brightway LCIA methods). + +## Formats + +### AB impact-category file (recommended default) + +Multi–impact-category file with self-describing metadata and variable-length method names. + +- **Excel:** sheets `CFs` and `Impact categories` (templates also include `README`) +- **CSV pair:** `*.cfs.csv` and `*.metadata.csv` + +| Sheet / file | Columns | +|---|---| +| CFs / `*.cfs.csv` | `method`, `flow`, `amount`, optional uncertainty fields | +| Impact categories (xlsx) / `*.metadata.csv` | `method`, `unit`, `description` | + +- `method` and `flow` use `::` (e.g. `My method::climate change::GWP100`, `Ammonia::air::unspecified`) +- Uncertainty columns: `uncertainty type`, `loc`, `scale`, `shape`, `minimum`, `maximum`, `negative` + +Menu: **Impact categories → Import → From AB LCIA file (.xlsx/.csv)…** / **Export → To AB LCIA file (.xlsx/.csv)…** + +### bw2io impact-category file + +Compatible with bw2io’s Excel/CSV LCIA CF table (`name`, `categories` with `::`, `amount`, optional uncertainty): **one impact category per CF file**. + +- **Excel:** CF sheet + AB `metadata` sheet (`filename`, `method`, `unit`, `description`) used to prefill the import dialog +- **CSV:** one CF file per impact category + shared `metadata.csv` sidecar (`filename` matches the CF file name when several rows exist) +- Exported file names join method-tuple parts with `__` (not `::`, which is illegal on Windows) and replace other forbidden characters with `-` + +Stock bw2io only needs the CF table; AB reads metadata when present and always shows a confirmation dialog. You can multi-select several Excel/CSV files in one import; shared CSV `metadata.csv` rows are matched by `filename`. + +Menu: **Impact categories → Import → From bw2io LCIA file (.xlsx/.csv)…** / **Export → To bw2io LCIA file (.xlsx/.csv)…** + +### ecoinvent LCIA Implementation Excel + +Vendor multi-method workbook (`CFs` + `units` / `Indicators`). Method names are three parts (`method` / `category` / `indicator`); flows use name / compartment / subcompartment columns. Here **Indicators** is ecoinvent’s sheet name (not AB’s metadata table). + +Menu: **Impact categories → Import → From ecoinvent Excel…** + +## Templates + +**Impact categories → Get template…** copies starters from `activity_browser/templates/impact-categories/`. AB impact-category file is listed first (default). + +## Import behaviour (AB and bw2io file imports) + +- Choose the biosphere database used for linking +- Multi–impact-category conflicts: skip existing / overwrite (with confirm) / rename with a namespace prefix; optional per-conflict rename table +- Single bw2io impact-category file name conflict: overwrite (with confirm), edit name, or cancel +- Unlinked characterization factors: dialog shows linked and unlinked counts; cancel, export unmatched list, or drop unlinked (no automatic biosphere creation) +- Long-running load, link, write, and export steps show a progress dialog with **Cancel**. Cancel stops the job. For import write, methods **created** in that run are rolled back so they do not remain in the project. **Overwrite runs are different:** if cancel happens after an existing impact category was already removed/replaced, that category may stay deleted or partially updated (the UI warns about this when overwrite is chosen). Full overwrite undo is out of scope for v1. + +## Export behaviour + +Menu export uses the current Impact categories pane selection. If nothing is selected (or the pane cannot be resolved), you are asked whether to export all impact categories. Export also shows a progress dialog while reading methods and writing files. \ No newline at end of file diff --git a/docs/agents/impact-category-interchange.md b/docs/agents/impact-category-interchange.md new file mode 100644 index 000000000..badaee31f --- /dev/null +++ b/docs/agents/impact-category-interchange.md @@ -0,0 +1,13 @@ +# Impact-category interchange (agent notes) + +Canonical user docs: [`docs/advanced-topics/impact-category-interchange.md`](../advanced-topics/impact-category-interchange.md). + +Glossary in root `CONTEXT.md`: **AB impact-category file**, **bw2io impact-category file**. Prefer “impact category” / “LCIA method”; avoid “Indicators” for AB metadata and avoid “one-shot” / “bw2io native”. + +Implementation seam: `activity_browser.bwutils.impact_categories` — `ab_lcia_file` / `bw2io_lcia_file` / `ecoinvent_lcia` / `common`; `ABLCIAImporter` links and writes prepared datasets (fed by AB or bw2io loaders). + +UI: one ABAction per file under `app.actions.method` (`method_import_*`, `method_export_*`, `method_get_template`). Action-specific dialogs and worker threads live in those same modules (repo convention). Shared sticky-cancel progress starter is `app.dialogs.run_thread_with_progress` (`ABProgressDialog` remains in `ui.dialogs`). Menu export selection is `live_impact_category_selection()` / `resolve_methods_for_export()` on the Impact categories pane. + +AB CSV pair suffixes: `.cfs.csv` + `.metadata.csv` (Excel sheet name remains **Impact categories**). Do not use “Indicators” for AB metadata. + +Templates: `activity_browser/templates/impact-categories/`. diff --git a/docs/user-interface/panes/impact-categories.md b/docs/user-interface/panes/impact-categories.md index e1f4076a9..38717af2d 100644 --- a/docs/user-interface/panes/impact-categories.md +++ b/docs/user-interface/panes/impact-categories.md @@ -20,4 +20,7 @@ The Impact Categories view displays a list of all impact categories in your proj ## Actions ### Open Impact Category -Open a database in the Database Product Pane by double-clicking the entry. +Open an impact category details page by double-clicking the entry (or use the context menu). + +### Import / export / templates +Use the **Impact categories** top menu to import, export, or get spreadsheet templates. Formats (AB impact-category file, bw2io impact-category file, ecoinvent) are described in [Impact category import and export](../../advanced-topics/impact-category-interchange.md). diff --git a/tests/test_ab_lcia_interchange.py b/tests/test_ab_lcia_interchange.py new file mode 100644 index 000000000..0bec53f9f --- /dev/null +++ b/tests/test_ab_lcia_interchange.py @@ -0,0 +1,289 @@ +"""Fast AB impact-category Excel interchange tests (bwutils seam).""" +from pathlib import Path + +import bw2data as bd +import pytest +from bw2data.tests import bw2test +from stats_arrays import LognormalUncertainty + +from activity_browser.bwutils.impact_categories import ( + ConflictMode, + apply_name_conflicts, + export_methods_ab_xlsx, + import_ab_methods, + join_tuple_path, + load_ab_xlsx, + split_tuple_path, +) +from fixtures.basic import DATABASE +from fixtures.bw_helpers import write_functional_database + + +def test_join_split_tuple_path(): + assert join_tuple_path(("My", "climate", "GWP")) == "My::climate::GWP" + assert split_tuple_path("My::climate::GWP") == ("My", "climate", "GWP") + assert split_tuple_path("single") == ("single",) + + +def test_apply_name_conflicts_rename_prefix(): + data = [ + {"name": ("IPCC", "GWP"), "unit": "kg", "description": "", "exchanges": []}, + {"name": ("new",), "unit": "kg", "description": "", "exchanges": []}, + ] + out = apply_name_conflicts( + data, + {("IPCC", "GWP")}, + mode=ConflictMode.RENAME_PREFIX, + prefix="Import", + ) + names = {ds["name"] for ds in out} + assert ("Import", "IPCC", "GWP") in names + assert ("new",) in names + + +def test_apply_name_conflicts_skip(): + data = [ + {"name": ("IPCC", "GWP"), "unit": "kg", "description": "", "exchanges": []}, + {"name": ("new",), "unit": "kg", "description": "", "exchanges": []}, + ] + out = apply_name_conflicts(data, {("IPCC", "GWP")}, mode=ConflictMode.SKIP) + assert [ds["name"] for ds in out] == [("new",)] + + +def test_apply_name_conflicts_per_row_renames(): + data = [ + {"name": ("IPCC", "GWP"), "unit": "kg", "description": "", "exchanges": []}, + {"name": ("kept",), "unit": "kg", "description": "", "exchanges": []}, + ] + out = apply_name_conflicts( + data, + {("IPCC", "GWP")}, + mode=ConflictMode.SKIP, + renames={("IPCC", "GWP"): ("IPCC", "GWP", "imported")}, + ) + assert [ds["name"] for ds in out] == [("IPCC", "GWP", "imported"), ("kept",)] + + +def test_ab_csv_sibling_resolution(tmp_path: Path): + from activity_browser.bwutils.impact_categories import ( + ab_csv_sibling_path, + resolve_ab_csv_pair, + ) + + cfs = tmp_path / "demo.cfs.csv" + ics = tmp_path / "demo.metadata.csv" + cfs.write_text("method,flow,amount\n", encoding="utf-8") + ics.write_text("method,unit,description\n", encoding="utf-8") + assert ab_csv_sibling_path(cfs) == ics + assert resolve_ab_csv_pair(cfs) == (cfs, ics) + assert resolve_ab_csv_pair(ics) == (cfs, ics) + + +def test_bw2io_metadata_csv_filename_match(tmp_path: Path): + from activity_browser.bwutils.impact_categories.bw2io_lcia_file import ( + read_bw2io_metadata_csv, + ) + + meta = tmp_path / "metadata.csv" + meta.write_text( + "filename,method,unit,description\n" + "a.csv,Method::A,kg,desc A\n" + "b.csv,Method::B,t,desc B\n", + encoding="utf-8", + ) + row = read_bw2io_metadata_csv(meta, cf_filename="b.csv") + assert row["method"] == "Method::B" + assert row["unit"] == "t" + assert read_bw2io_metadata_csv(meta, cf_filename="missing.csv") is None + + +def test_bw2io_metadata_csv_single_row_fallback(tmp_path: Path): + from activity_browser.bwutils.impact_categories.bw2io_lcia_file import ( + read_bw2io_metadata_csv, + ) + + meta = tmp_path / "metadata.csv" + meta.write_text( + "method,unit,description\none::method,kg,only\n", + encoding="utf-8", + ) + row = read_bw2io_metadata_csv(meta, cf_filename="anything.csv") + assert row["method"] == "one::method" + + +def test_method_name_to_filename_stem_is_cross_platform(): + from activity_browser.bwutils.impact_categories import method_name_to_filename_stem + + stem = method_name_to_filename_stem(("IPCC", "climate change", "GWP100")) + assert stem == "IPCC__climate change__GWP100" + assert ":" not in stem + assert "/" not in stem + assert "\\" not in stem + dirty = method_name_to_filename_stem(("a:b", "c/d", "e|f")) + assert ":" not in dirty + assert "/" not in dirty + assert "|" not in dirty + + +@bw2test +def test_ab_csv_round_trip(tmp_path: Path): + from activity_browser.bwutils.impact_categories import ( + export_methods_ab_csv_pair, + load_ab_csv_pair, + ) + + write_functional_database("basic", DATABASE, process=True) + name = ("climate", "gwp") + method = bd.Method(name) + method.register(unit="kg", description="GWP demo") + method.write([(("basic", "elementary"), 1.5)], process=True) + bd.methods.flush() + + export_methods_ab_csv_pair([name], tmp_path / "demo") + loaded = load_ab_csv_pair(tmp_path / "demo.cfs.csv") + assert len(loaded) == 1 + assert loaded[0]["name"] == name + assert loaded[0]["exchanges"][0]["amount"] == 1.5 + + +@bw2test +def test_bw2io_xlsx_round_trip(tmp_path: Path): + from activity_browser.bwutils.impact_categories.bw2io_lcia_file import ( + export_method_bw2io_xlsx, + load_bw2io_lcia_file, + read_bw2io_metadata_xlsx, + ) + + write_functional_database("basic", DATABASE, process=True) + name = ("climate", "gwp") + method = bd.Method(name) + method.register(unit="kg", description="GWP demo") + method.write([(("basic", "elementary"), 3.0)], process=True) + bd.methods.flush() + + out = tmp_path / "one.xlsx" + export_method_bw2io_xlsx(name, out) + meta = read_bw2io_metadata_xlsx(out) + assert meta["method"] == "climate::gwp" + assert meta["filename"] == "one.xlsx" + data = load_bw2io_lcia_file( + out, name=name, unit=meta["unit"], description=meta["description"] + ) + assert data[0]["exchanges"][0]["amount"] == 3.0 + assert data[0]["exchanges"][0]["categories"] == ("air",) + + +@bw2test +def test_ab_xlsx_round_trip_preserves_uncertainty(tmp_path: Path): + write_functional_database("basic", DATABASE, process=True) + cfs = [ + ( + ("basic", "elementary"), + { + "amount": 2.5, + "uncertainty type": LognormalUncertainty.id, + "loc": 0.9, + "scale": 0.2, + "negative": False, + }, + ) + ] + name = ("climate", "gwp") + method = bd.Method(name) + method.register(unit="kg", description="GWP demo") + method.write(cfs, process=True) + bd.methods.flush() + + out = tmp_path / "ab-lcia.xlsx" + export_methods_ab_xlsx([name], out) + + loaded = load_ab_xlsx(out) + assert len(loaded) == 1 + ds = loaded[0] + assert ds["name"] == name + assert ds["unit"] == "kg" + assert ds["description"] == "GWP demo" + assert len(ds["exchanges"]) == 1 + exc = ds["exchanges"][0] + assert exc["name"] == "elementary" + assert exc["categories"] == ("air",) + assert exc["amount"] == 2.5 + assert exc["uncertainty type"] == LognormalUncertainty.id + assert exc["loc"] == pytest.approx(0.9) + assert exc["scale"] == pytest.approx(0.2) + + del bd.methods[name] + bd.methods.flush() + stats = import_ab_methods( + loaded, + biosphere_name="basic", + conflict_mode=ConflictMode.OVERWRITE, + ) + assert stats.written == 1 + assert stats.unlinked == 0 + written = list(bd.Method(name).load()) + assert len(written) == 1 + payload = written[0][1] + assert isinstance(payload, dict) + assert payload["amount"] == 2.5 + assert payload["uncertainty type"] == LognormalUncertainty.id + assert bd.methods[name].get("description") == "GWP demo" + + +@bw2test +def test_import_ab_methods_blocks_on_unlinked(): + write_functional_database("basic", DATABASE, process=True) + data = [ + { + "name": ("climate", "gwp"), + "unit": "kg", + "description": "", + "filename": "test.xlsx", + "exchanges": [ + { + "name": "does-not-exist", + "categories": ("air",), + "amount": 1.0, + } + ], + } + ] + stats = import_ab_methods(data, biosphere_name="basic") + assert stats.written == 0 + assert stats.unlinked == 1 + assert ("climate", "gwp") not in bd.methods + + +@bw2test +def test_import_ab_methods_drop_unlinked_writes_linked_only(): + write_functional_database("basic", DATABASE, process=True) + data = [ + { + "name": ("climate", "gwp"), + "unit": "kg", + "description": "", + "filename": "test.xlsx", + "exchanges": [ + { + "name": "elementary", + "categories": ("air",), + "amount": 2.0, + }, + { + "name": "does-not-exist", + "categories": ("air",), + "amount": 9.0, + }, + ], + } + ] + stats = import_ab_methods( + data, biosphere_name="basic", drop_unlinked=True + ) + assert stats.written == 1 + assert stats.unlinked == 0 + cfs = list(bd.Method(("climate", "gwp")).load()) + assert len(cfs) == 1 + assert cfs[0][1] == 2.0 or ( + isinstance(cfs[0][1], dict) and cfs[0][1]["amount"] == 2.0 + ) diff --git a/tests/test_activity_edit_elementary_flow.py b/tests/test_activity_edit_elementary_flow.py index ac48791cd..e77d14224 100644 --- a/tests/test_activity_edit_elementary_flow.py +++ b/tests/test_activity_edit_elementary_flow.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.app.actions.activity import edit_elementary_flow as edit_mod +from activity_browser.app.actions.activity import elementary_flow_edit as edit_mod from activity_browser.bwutils.elementary_flows import create_elementary_flow from activity_browser.bwutils.commontasks import is_node_biosphere diff --git a/tests/test_activity_new_elementary_flow.py b/tests/test_activity_new_elementary_flow.py index ec2f2ebc7..130f5e331 100644 --- a/tests/test_activity_new_elementary_flow.py +++ b/tests/test_activity_new_elementary_flow.py @@ -3,7 +3,7 @@ from qtpy import QtWidgets from activity_browser import app -from activity_browser.app.actions.activity import new_elementary_flow as mod +from activity_browser.app.actions.activity import elementary_flow_new as mod from activity_browser.bwutils.commontasks import is_node_biosphere diff --git a/tests/test_impact_category_templates.py b/tests/test_impact_category_templates.py new file mode 100644 index 000000000..a6209712e --- /dev/null +++ b/tests/test_impact_category_templates.py @@ -0,0 +1,28 @@ +"""Impact-category template path/copy smoke tests.""" +from pathlib import Path + +from activity_browser.bwutils.impact_categories.templates import ( + TEMPLATE_FILES, + copy_impact_category_template, + template_paths, +) + + +def test_impact_category_template_paths_exist(): + for kind in TEMPLATE_FILES: + paths = template_paths(kind) + assert paths + assert all(p.is_file() for p in paths) + + +def test_copy_ab_xlsx_template(tmp_path: Path): + dest = tmp_path / "out.xlsx" + written = copy_impact_category_template("ab-xlsx", dest) + assert len(written) == 1 + assert written[0].is_file() + + +def test_copy_ab_csv_template_pair(tmp_path: Path): + written = copy_impact_category_template("ab-csv", tmp_path / "my-lcia") + assert len(written) == 2 + assert all(p.is_file() for p in written) diff --git a/tests/test_method_import_progress_dialog.py b/tests/test_method_import_progress_dialog.py new file mode 100644 index 000000000..29bcb6b22 --- /dev/null +++ b/tests/test_method_import_progress_dialog.py @@ -0,0 +1,28 @@ +"""Source-level smoke checks for method import progress wiring (no app startup).""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +METHOD = ROOT / "activity_browser" / "app" / "actions" / "method" +APP_DIALOGS = ROOT / "activity_browser" / "app" / "dialogs" + + +def test_method_import_ecoinvent_uses_ui_progress_dialog(): + src = (METHOD / "method_import_ecoinvent.py").read_text(encoding="utf-8") + assert "from activity_browser.ui.dialogs import ABProgressDialog" in src + assert "widgets.ABProgressDialog" not in src + assert "composites" not in src + + +def test_method_file_actions_use_run_thread_with_progress(): + progress_src = (APP_DIALOGS / "thread_progress.py").read_text(encoding="utf-8") + assert "def run_thread_with_progress(" in progress_src + + for name in ( + "method_import_ab.py", + "method_import_bw2io.py", + "method_export_ab.py", + "method_export_bw2io.py", + ): + src = (METHOD / name).read_text(encoding="utf-8") + assert "run_thread_with_progress" in src, name + assert "from activity_browser.app.dialogs import run_thread_with_progress" in src, name From 8a215885108b1ede8abaf58e81ab8454474a9c4c Mon Sep 17 00:00:00 2001 From: bsteubing Date: Tue, 11 Aug 2026 01:57:10 +0200 Subject: [PATCH 05/10] fix for tests --- activity_browser/app/panes/impact_categories.py | 17 ++++++++++------- .../bwutils/impact_categories/ab_lcia_file.py | 1 + .../bwutils/impact_categories/ecoinvent_lcia.py | 4 +++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/activity_browser/app/panes/impact_categories.py b/activity_browser/app/panes/impact_categories.py index 66d8c7e11..b314534b4 100644 --- a/activity_browser/app/panes/impact_categories.py +++ b/activity_browser/app/panes/impact_categories.py @@ -107,16 +107,19 @@ def sync(self): self.model.set_dataframe(df, group=["_method_name"]) def build_df(self): - df = pd.DataFrame(bd.methods.values()) - df["_method_name"] = bd.methods.keys() - - df["name"] = df["_method_name"].apply(lambda x: x[-1]) - cols = ["name", "unit", "num_cfs", "_method_name"] - - if df.empty: + if not bd.methods: return pd.DataFrame(columns=cols) + df = pd.DataFrame(list(bd.methods.values())) + df["_method_name"] = list(bd.methods.keys()) + df["name"] = df["_method_name"].apply(lambda x: x[-1] if x else "") + if "unit" not in df.columns: + df["unit"] = "" + if "num_cfs" not in df.columns: + df["num_cfs"] = 0 + else: + df["num_cfs"] = df["num_cfs"].fillna(0) return df[cols] diff --git a/activity_browser/bwutils/impact_categories/ab_lcia_file.py b/activity_browser/bwutils/impact_categories/ab_lcia_file.py index 898542506..66c59ea98 100644 --- a/activity_browser/bwutils/impact_categories/ab_lcia_file.py +++ b/activity_browser/bwutils/impact_categories/ab_lcia_file.py @@ -326,6 +326,7 @@ def write_methods(self, overwrite=False, verbose=True, cancel_check=None): description=ds.get("description") or "", filename=ds.get("filename") or "", unit=ds.get("unit") or "", + num_cfs=len(cfs), ) method.write(cfs) written_names.append(name) diff --git a/activity_browser/bwutils/impact_categories/ecoinvent_lcia.py b/activity_browser/bwutils/impact_categories/ecoinvent_lcia.py index 07fe41307..c79e7c8f9 100644 --- a/activity_browser/bwutils/impact_categories/ecoinvent_lcia.py +++ b/activity_browser/bwutils/impact_categories/ecoinvent_lcia.py @@ -108,12 +108,14 @@ def write_methods(self, overwrite=False, verbose=True): ) del methods[name] method = Method(name) + cfs = self._reformat_cfs(ds["exchanges"]) method.register( description=ds["description"], filename=ds["filename"], unit=ds["unit"], + num_cfs=len(cfs), ) - method.write(self._reformat_cfs(ds["exchanges"])) + method.write(cfs) if verbose: print( f"Wrote {num_methods} LCIA methods with {num_cfs} characterization factors" From c46d356d4104f4ba9f1762a27fa0f297b5695986 Mon Sep 17 00:00:00 2001 From: bsteubing Date: Tue, 11 Aug 2026 22:47:40 +0200 Subject: [PATCH 06/10] First working version of Contribution Tree tab --- CONTEXT.md | 46 + .../calculation_setup/calculation_setup.py | 2 +- .../app/pages/lca_results/LCA_results.py | 19 +- .../lca_results/contribution_tree_tab.py | 1769 +++++++++++++++++ activity_browser/bwutils/contribution_tree.py | 645 ++++++ activity_browser/ui/delegates/__init__.py | 3 + .../ui/delegates/impact_background.py | 114 ++ tests/test_contribution_tree.py | 645 ++++++ tests/test_impact_background.py | 31 + 9 files changed, 3263 insertions(+), 11 deletions(-) create mode 100644 activity_browser/app/pages/lca_results/contribution_tree_tab.py create mode 100644 activity_browser/bwutils/contribution_tree.py create mode 100644 activity_browser/ui/delegates/impact_background.py create mode 100644 tests/test_contribution_tree.py create mode 100644 tests/test_impact_background.py diff --git a/CONTEXT.md b/CONTEXT.md index d175f07f7..88a807257 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -123,6 +123,52 @@ Command-style operation under `activity_browser/app/actions/` (menus, toolbars, Extensibility mechanism for third-party AB features. **Architecture TBD** — do not invent API contracts; document here when redesigned. +### Contribution tree + +A hierarchical, acyclic breakdown of LCA impact by upstream supplier, produced by priority-first graph traversal (`SameNodeEachVisitGraphTraversal`). Each node carries a **cumulative impact** (its own direct emissions plus all upstream) and a **direct impact** (its own biosphere flows only). The root is the functional unit; children are direct technosphere suppliers, recursed up the supply chain. Shown in AB as a `QTreeView` with one row per traversed node, in the "Contribution Tree" tab of the LCA Results page. Nodes are calculated lazily on expand; an **expand policy** controls how far auto-expand walks. +_Avoid_: supply-chain tree, upstream tree (use contribution tree in AB UI; "upstream tree" is the OpenLCA term for the same concept) + +### Tier (contribution-tree depth) + +The distance from the functional unit in the contribution tree. The functional unit is tier 0; its direct suppliers are tier 1; their suppliers are tier 2; and so on. Not to be confused with the sequential first-tier substitution approach used in the (disabled) `FirstTierContributionsTab`. +_Avoid_: level, depth (fine internally but use "tier" in UI labels and the Tier column) + +### Cumulative impact + +The absolute LCA score attributable to a contribution-tree node, including all its upstream suppliers (`node.cumulative_score`). Shown as a column in the contribution tree table and as wedge size in the sunburst plot. The **Cumulative impact (%)** column expresses this as a fraction of the total LCA score. +_Avoid_: upstream total, total result, cumulative score (use cumulative impact in UI labels) + +### Direct impact + +The absolute LCA score from a node's own biosphere flows only, excluding its upstream (`node.direct_emissions_score`). Shown as a separate column in the contribution tree table. +_Avoid_: direct contribution, direct emissions score (use direct impact in UI labels) + +### Direct-impact coverage + +Two related ratios, both Σ(direct impact) / |total score| (equivalent to summing the **Direct impact (%)** column): + +- **Shown (footer):** only rows currently visible in the tree (ancestors expanded). Updates on expand/collapse. +- **Calculated (footer):** all nodes discovered by graph traversal (excluding the virtual demand root). The Cumulative expand “(target X% — not reached)” note uses this when even the full calculated graph stays below the target. +- **Cumulative expand display set:** largest-first by remaining upstream (|cumulative| − |direct|). Nodes that are already almost entirely direct are not auto-opened. When a node is opened, children are added largest-first and stop once Σ(direct of included) reaches the target %. Leftover siblings stay out of the model (manual expand can still reveal them). + +Footer format: `Shown: N nodes, Y% of direct impacts, max tier T | Calculated: Z nodes, A% of direct impacts, max tier U`. +_Avoid_: traversal coverage, score coverage (unless clearly meaning this ratio) + +### Path impact + +The cumulative impact of a contribution-tree node as a share of the total LCA score — i.e. how much of the result flows through that supply-chain path. Shown as **Cumulative impact (%)**. The **Individual path impact** expand policy auto-opens nodes at/above a chosen path % only while a child at/above that % remains (terminal high-path nodes stay collapsed); under opened nodes it lists all discovered siblings. Only the engine traversal **cutoff** omits smaller branches from calculation. +_Avoid_: individual impact (alone), branch score + +### Expand policy + +How far auto-expand calculates and visually opens the contribution tree. Modes: **Tier** (open down to a given tier), **Individual path impact** (keep expanding while path impact ≥ X% continues into a child; list all discovered children under opened nodes; leave terminal ≥ X% rows collapsed), **Cumulative impact** (largest-first from the reference flow until the **display set**’s direct-impact coverage reaches a target %, capped below 100% — does not open every previously calculated node). Distinct from a later optional **display filter** that only hides already-calculated rows. Open branches and which rows are in the tree are remembered per RF / impact category / scenario / cutoff when switching selections in the Contribution Tree tab. +_Avoid_: cutoff (alone — ambiguous with Process Contributions and engine traversal cutoff) + +### Flow amount + +The scaled technosphere demand for a contribution-tree node (`node.supply_amount`), expressed in the reference product's unit. Shown in the "Flow amount" and "Unit" columns of the contribution tree table. +_Avoid_: required amount, supply amount (use flow amount in UI labels) + ## Synonyms to avoid (prefer glossary term) | Avoid drifting to… | Prefer | diff --git a/activity_browser/app/pages/calculation_setup/calculation_setup.py b/activity_browser/app/pages/calculation_setup/calculation_setup.py index 5a6c455eb..438d38644 100644 --- a/activity_browser/app/pages/calculation_setup/calculation_setup.py +++ b/activity_browser/app/pages/calculation_setup/calculation_setup.py @@ -44,7 +44,7 @@ def build_layout(self): top_layout = QtWidgets.QHBoxLayout() top_layout.setContentsMargins(0, 0, 10, 0) - top_layout.addWidget(widgets.ABLabel.demiBold(" Functional Units:", self)) + top_layout.addWidget(widgets.ABLabel.demiBold(" Reference flows:", self)) top_layout.addStretch() top_layout.addWidget(self.type_dropdown) top_layout.addWidget(self.run_button) diff --git a/activity_browser/app/pages/lca_results/LCA_results.py b/activity_browser/app/pages/lca_results/LCA_results.py index dd089f293..e92887e65 100644 --- a/activity_browser/app/pages/lca_results/LCA_results.py +++ b/activity_browser/app/pages/lca_results/LCA_results.py @@ -61,13 +61,14 @@ GSAPlot, ) from .sankey_navigator import SankeyNavigatorWidget +from .contribution_tree_tab import ContributionTreeTab ca = ABContributionAnalysis() # Special namedtuple for the LCAResults TabWidget. Tabs = namedtuple( - "tabs", ("inventory", "results", "ef", "process", "sankey", "tree", "mc", "gsa") + "tabs", ("inventory", "results", "ef", "process", "contribution_tree", "sankey", "mc", "gsa") ) Relativity = namedtuple("relativity", ("relative", "absolute")) TotalMenu = namedtuple("total_menu", ("score", "range")) @@ -132,9 +133,8 @@ def __init__(self, cs_name, mlca, contributions, mc, parent=None): results=LCAResultsTab(self), ef=ElementaryFlowContributionTab(self), process=ProcessContributionsTab(self), - # ft=FirstTierContributionsTab(self.cs_name, parent=self), + contribution_tree=ContributionTreeTab(self), sankey=SankeyNavigatorWidget(self.cs_name, parent=self), - tree=None, mc=MonteCarloTab(self), # mc=None if self.mc is None else MonteCarloTab(self), gsa=GSATab(self), ) @@ -143,9 +143,8 @@ def __init__(self, cs_name, mlca, contributions, mc, parent=None): results="LCA scores", ef="EF Contributions", process="Process Contributions", - # ft="FT Contributions", + contribution_tree="Contribution Tree", sankey="Sankey", - tree=None, mc="Monte Carlo", gsa="Sensitivity Analysis", ) @@ -175,11 +174,11 @@ def generate_content_on_click(self, index): if not self.tabs.sankey.has_sankey: logger.info("Generating Sankey Tab") self.tabs.sankey.new_sankey() - # elif index == self.indexOf(self.tabs.ft): - # if not self.tabs.ft.has_been_opened: - # logger.info("Generating First Tier results") - # self.tabs.ft.has_been_opened = True - # self.tabs.ft.update_tab() + elif index == self.indexOf(self.tabs.contribution_tree): + if not self.tabs.contribution_tree.has_been_opened: + logger.info("Generating Contribution Tree Tab") + self.tabs.contribution_tree.has_been_opened = True + self.tabs.contribution_tree.update_tab() class NewAnalysisTab(QtWidgets.QWidget): diff --git a/activity_browser/app/pages/lca_results/contribution_tree_tab.py b/activity_browser/app/pages/lca_results/contribution_tree_tab.py new file mode 100644 index 000000000..2d415a370 --- /dev/null +++ b/activity_browser/app/pages/lca_results/contribution_tree_tab.py @@ -0,0 +1,1769 @@ +"""Contribution Tree tab for the LCA Results page. + +Shows the contribution tree as a hierarchical QTreeView (one row per +traversed upstream supplier) with a sunburst plot above it. + +Tickets implemented here: 03, 04, 05, 06, 07, 08, 09, 10–13. +""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Optional + +import bw2data as bd +import bw2calc as bc +from loguru import logger +from qtpy import QtCore, QtGui, QtWidgets +from qtpy.QtCore import Slot + +from bw_graph_tools.graph_traversal import ( + SameNodeEachVisitGraphTraversal, + GraphTraversalSettings, +) + +from activity_browser import app +from activity_browser.bwutils.contribution_tree import ( + build_parent_child_map, + compute_node_tiers, + cumulative_percent, + direct_impact_coverage, + direct_percent, + plan_cumulative_expand, + path_display_set, + build_sunburst_rings, + flatten_to_dataframe, + next_expand_candidates, + suppress_graph_traversal_warnings, +) +from activity_browser.bwutils.export_names import lca_export_basename +from activity_browser.ui import widgets +from activity_browser.ui.delegates.impact_background import ImpactBackgroundDelegate + +from .combobox_utils import configure_scenario_widgets, scenario_labels, update_combobox +from .style import SmallComboBox, apply_lca_combo_width, lca_header_layout, lca_help_tool_button, lca_tab_control_row + +if TYPE_CHECKING: + pass + +# Column indices — keep in sync with COLUMNS list +COL_CUMULATIVE_PCT = 0 +COL_DIRECT_PCT = 1 +COL_PRODUCT = 2 +COL_PROCESS = 3 +COL_LOCATION = 4 +COL_DATABASE = 5 +COL_FLOW_AMOUNT = 6 +COL_UNIT = 7 +COL_CUMULATIVE = 8 +COL_DIRECT = 9 +COL_TIER = 10 + +COLUMNS = [ + "Cumulative impact (%)", + "Direct impact (%)", + "Product", + "Process", + "Location", + "Database", + "Flow amount", + "Unit", + "Cumulative impact", + "Direct impact", + "Tier", +] + +# Columns that get the impact-background delegate (signed magnitude values) +BAR_COLUMNS = (COL_CUMULATIVE_PCT, COL_DIRECT_PCT, COL_CUMULATIVE, COL_DIRECT) + +EXPAND_MODE_TIER = "tier" +EXPAND_MODE_PATH = "path" +EXPAND_MODE_CUMULATIVE = "cumulative" + +# Role for contribution-tree node unique_id on the first-column item +UID_ROLE = QtCore.Qt.UserRole + 1 +PLACEHOLDER_ROLE = QtCore.Qt.UserRole + 2 +TIER_ROLE = QtCore.Qt.UserRole + 3 + + +HELP_TEXT = """ + + Contribution Tree shows how impact accumulates along the supply chain +of one reference flow and impact category (and scenario, when present).
+ +Tree table
+ +
+Each row is a process on a supply path. Cumulative impact (%) is the share +of the total score that flows through that path (path impact). +Direct impact (%) is only the characterised emissions of that process itself. +The reference flow is tier 0; its suppliers are tier 1, and so on. +Expand a row manually to calculate and list all of its suppliers.Cutoff
+ +
+Engine threshold for Brightway graph traversal: branches whose path impact is +below this percent of the total score are not followed further during calculation.Expand to
+ +
+• Tier — calculate and open the tree down to the chosen tier.
+• Individual path impact — calculate and open every node whose +path (cumulative) impact is at least X% of the total and that still +has a child ≥ X% (the high-impact path continues). Under those opened nodes +list all discovered siblings (including below X%). A terminal ≥ X% node +stays collapsed until you expand it manually. Only the engine Cutoff +omits smaller branches from calculation.
+• Cumulative impact — from the reference flow, open the +largest paths first until Σ(direct impact) of the rows in that tree +reaches X% of the total. Children of an opened node are added largest-first +and stop once the target is met (collapse and re-expand a row to list every +child). Further Brightway traversal runs only when the next node to open +is not yet calculated. If the engine cutoff stops discovery early, the +footer shows that the target was not reached.Sunburst
+ +
+Layers match tiers. Plot tiers controls how many rings are drawn; it does +not change the table.Footer
+ +""" + + +# --------------------------------------------------------------------------- +# Per-selection cache (RF / IC / scenario / cutoff) +# --------------------------------------------------------------------------- + +@dataclass +class ContributionTreeCacheEntry: + """Cached graph plus the Qt tree view snapshot for one selection. + + ``model_uids`` is the set of rows that were in the tree (e.g. after path + prune). ``None`` means unrestricted — use whatever ``load_state`` builds. + """ + + state: SameNodeEachVisitGraphTraversal + expanded_uids: set[int] = field(default_factory=set) + model_uids: set[int] | None = None + + +# --------------------------------------------------------------------------- +# Tree item model (Ticket 03) +# --------------------------------------------------------------------------- + +class ContributionTreeModel(QtGui.QStandardItemModel): + """QStandardItemModel backed by a SameNodeEachVisitGraphTraversal state. + + Populated lazily: call ``load_state`` after initial traversal, then + ``expand_node`` from a queued ``expanded`` handler. Empty placeholder + children provide expand chevrons without visible ellipsis text. + """ + + column_max_changed = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(0, len(COLUMNS), parent) + self.setHorizontalHeaderLabels(COLUMNS) + self._state: Optional[SameNodeEachVisitGraphTraversal] = None + self._total_score: float = 0.0 + self._root_uid: int | None = None + self._tiers: dict[int, int] = {} + # Maps unique_id → QStandardItem (the first-column item for that row) + self._uid_to_item: dict[int, QtGui.QStandardItem] = {} + # Column max values for the bar-background delegates + self.col_max: dict[int, float] = {c: 1.0 for c in BAR_COLUMNS} + self._expanding: bool = False + self._meta_cache: dict = {} + self._batch_updating: bool = False + + @staticmethod + def _has_real_children(item: QtGui.QStandardItem) -> bool: + for row in range(item.rowCount()): + child = item.child(row, 0) + if child is not None and not child.data(PLACEHOLDER_ROLE): + return True + return False + + def _strip_placeholders(self, parent_item: QtGui.QStandardItem) -> None: + for row in range(parent_item.rowCount() - 1, -1, -1): + child = parent_item.child(row, 0) + if child is not None and child.data(PLACEHOLDER_ROLE): + parent_item.removeRow(row) + + def _ensure_placeholder(self, first: QtGui.QStandardItem) -> None: + """Empty child so the view shows a chevron (no visible ellipsis text).""" + if first.rowCount() > 0: + return + ph = QtGui.QStandardItem("") + ph.setEditable(False) + ph.setData(True, PLACEHOLDER_ROLE) + ph.setFlags(QtCore.Qt.ItemFlag.NoItemFlags) + first.appendRow( + [ph] + [QtGui.QStandardItem("") for _ in range(len(COLUMNS) - 1)] + ) + + def _refresh_tiers(self) -> None: + if self._state is None or self._root_uid is None: + self._tiers = {} + return + self._tiers = compute_node_tiers( + self._state.nodes, self._state.edges, self._root_uid + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def load_state( + self, + state: SameNodeEachVisitGraphTraversal, + total_score: float, + ) -> None: + """Rebuild the model from a (possibly cached) traversal state.""" + self.clear() + self.setHorizontalHeaderLabels(COLUMNS) + self._state = state + self._total_score = total_score + self._uid_to_item = {} + self.col_max = {c: 1.0 for c in BAR_COLUMNS} + self._meta_cache = {} + self._root_uid = state._root_node.unique_id + self._refresh_tiers() + + pcm = build_parent_child_map(state.nodes, state.edges) + root_children = [ + state.nodes[uid] + for uid in pcm.get(self._root_uid, []) + if uid in state.nodes + ] + root_children.sort(key=lambda n: abs(n.cumulative_score), reverse=True) + self._batch_updating = True + try: + for child in root_children: + self._add_node(child, self.invisibleRootItem(), pcm) + finally: + self._batch_updating = False + + def expand_node( + self, + unique_id: int, + min_path_pct: float | None = None, + ) -> bool: + """Traverse from the given node and add its direct children to the model. + + All children discovered by graph traversal are listed (the engine cutoff + already limits which edges exist). ``min_path_pct`` is ignored for + listing — it only affects auto-expand policy elsewhere. + """ + if self._state is None or self._expanding: + return False + + parent_item = self._uid_to_item.get(unique_id) + if parent_item is None: + return False + + self._expanding = True + try: + self._strip_placeholders(parent_item) + + if unique_id not in self._state.visited_nodes: + node = self._state.nodes.get(unique_id) + if node is None: + parent_item.emitDataChanged() + return False + # Brightway computes max_depth from node.depth *before* resetting + # depth to 0. Without zeroing here, traverse_from_node(depth=1) on + # a mid-tree node walks old_depth+1 levels and marks direct + # children as visited — they then get no expand chevrons. + node.depth = 0 + with suppress_graph_traversal_warnings(): + if not self._state.traverse_from_node(unique_id, depth=1): + parent_item.emitDataChanged() + return False + + # Avoid full-graph tier BFS on every expand; new rows use parent+1. + pcm = build_parent_child_map(self._state.nodes, self._state.edges) + child_nodes = [ + self._state.nodes[uid] + for uid in pcm.get(unique_id, []) + if uid not in self._uid_to_item and uid in self._state.nodes + ] + child_nodes.sort(key=lambda n: abs(n.cumulative_score), reverse=True) + for child_node in child_nodes: + self._add_node(child_node, parent_item, pcm, recurse_known=False) + + if self._has_real_children(parent_item): + if not self._batch_updating: + self.column_max_changed.emit() + return True + + parent_item.emitDataChanged() + return False + finally: + self._expanding = False + + def prune_below_threshold(self, min_path_pct: float) -> None: + """Drop rows whose path (cumulative) impact is below ``min_path_pct``. + + Kept for rare callers; individual path expand no longer uses this — + siblings below the expand threshold stay listed under open parents. + """ + if self._state is None: + return + to_remove = [ + uid + for uid, item in self._uid_to_item.items() + if (node := self._state.nodes.get(uid)) is not None + and int(item.data(TIER_ROLE) or 0) > 0 + and abs(cumulative_percent(node, self._total_score)) < min_path_pct + ] + to_remove.sort( + key=lambda u: int(self._uid_to_item[u].data(TIER_ROLE) or 0), + reverse=True, + ) + for uid in to_remove: + item = self._uid_to_item.get(uid) + if item is None: + continue + parent = item.parent() + if parent is None: + parent = self.invisibleRootItem() + row = item.row() + self._forget_subtree(item) + parent.removeRow(row) + + pcm = build_parent_child_map(self._state.nodes, self._state.edges) + for uid, item in self._uid_to_item.items(): + if self._has_real_children(item): + continue + if self._has_hidden_children(uid, pcm): + self._ensure_placeholder(item) + + def restrict_to_uids(self, keep: set[int]) -> None: + """Remove rows whose unique_id is not in ``keep`` (deepest first). + + Used when restoring a cached view after path-impact prune (or any + other filter that left a subset of the traversal in the model). + """ + if self._state is None: + return + to_remove = [uid for uid in self._uid_to_item if uid not in keep] + to_remove.sort( + key=lambda u: int(self._uid_to_item[u].data(TIER_ROLE) or 0), + reverse=True, + ) + for uid in to_remove: + item = self._uid_to_item.get(uid) + if item is None: + continue + parent = item.parent() + if parent is None: + parent = self.invisibleRootItem() + row = item.row() + self._forget_subtree(item) + parent.removeRow(row) + + pcm = build_parent_child_map(self._state.nodes, self._state.edges) + for uid, item in list(self._uid_to_item.items()): + if self._has_real_children(item): + continue + if self._has_hidden_children(uid, pcm): + self._ensure_placeholder(item) + + def _forget_subtree(self, item: QtGui.QStandardItem) -> None: + for row in range(item.rowCount()): + child = item.child(row, 0) + if child is not None and not child.data(PLACEHOLDER_ROLE): + self._forget_subtree(child) + uid = item.data(UID_ROLE) + if uid is not None: + self._uid_to_item.pop(uid, None) + + def _has_hidden_children(self, unique_id: int, pcm: dict | None = None) -> bool: + if self._state is None: + return False + if pcm is None: + pcm = build_parent_child_map(self._state.nodes, self._state.edges) + return any( + cid not in self._uid_to_item and cid in self._state.nodes + for cid in pcm.get(unique_id, []) + ) + + def to_dataframe(self, metadata_lookup=None): + """Return a flat DataFrame of all traversed nodes.""" + if self._state is None: + import pandas as pd + return pd.DataFrame(columns=COLUMNS) + return flatten_to_dataframe( + self._state.nodes, + self._state.edges, + self._total_score, + metadata_lookup=metadata_lookup, + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _add_node( + self, + node, + parent_item: QtGui.QStandardItem, + pcm: dict, + *, + recurse_known: bool = True, + ) -> None: + """Create a row of QStandardItems for ``node`` under ``parent_item``.""" + if node.unique_id in self._uid_to_item: + return + + meta = self._resolve_meta(node) + total = self._total_score + # Tier from edge distance to FU — never Brightway node.depth after lazy expand + tier = self._tiers.get(node.unique_id) + if tier is None: + if parent_item is self.invisibleRootItem(): + tier = 0 + else: + parent_tier = parent_item.data(TIER_ROLE) + tier = (int(parent_tier) + 1) if parent_tier is not None else 0 + + cum_pct = cumulative_percent(node, total) + dir_pct = direct_percent(node, total) + + def _item(text, value=None, numeric=False): + it = QtGui.QStandardItem() + it.setText(str(text)) + it.setEditable(False) + if value is not None: + it.setData(value, ImpactBackgroundDelegate.VALUE_ROLE) + if numeric and isinstance(value, float): + it.setData(value, QtCore.Qt.UserRole) + return it + + row = [ + _item(f"{cum_pct:.2f}", value=cum_pct, numeric=True), + _item(f"{dir_pct:.2f}", value=dir_pct, numeric=True), + _item(meta.get("product", "")), + _item(meta.get("name", "")), + _item(meta.get("location", "")), + _item(meta.get("database", "")), + _item(f"{node.supply_amount:.4g}"), + _item(meta.get("unit", "")), + _item(f"{node.cumulative_score:.4g}", value=node.cumulative_score, numeric=True), + _item( + f"{node.direct_emissions_score:.4g}", + value=node.direct_emissions_score, + numeric=True, + ), + _item(str(tier)), + ] + + is_visited = node.unique_id in (self._state.visited_nodes if self._state else set()) + has_children = bool(pcm.get(node.unique_id)) + is_leaf = is_visited and not has_children + if not is_visited and tier > 0: + row[COL_PROCESS].setForeground(QtGui.QBrush(QtGui.QColor("#888888"))) + row[COL_PROCESS].setToolTip("Not yet expanded — click to explore") + + if is_leaf: + for item in row: + font = item.font() + font.setItalic(True) + item.setFont(font) + + parent_item.appendRow(row) + first = row[COL_CUMULATIVE_PCT] + first.setData(node.unique_id, UID_ROLE) + first.setData(tier, TIER_ROLE) + self._uid_to_item[node.unique_id] = first + + self._update_col_max(COL_CUMULATIVE_PCT, abs(cum_pct)) + self._update_col_max(COL_DIRECT_PCT, abs(dir_pct)) + self._update_col_max(COL_CUMULATIVE, abs(node.cumulative_score)) + self._update_col_max(COL_DIRECT, abs(node.direct_emissions_score)) + + if recurse_known: + known_children = pcm.get(node.unique_id, []) + child_nodes = [ + self._state.nodes[uid] + for uid in known_children + if self._state and uid in self._state.nodes and uid not in self._uid_to_item + ] + child_nodes.sort(key=lambda n: abs(n.cumulative_score), reverse=True) + for child_node in child_nodes: + self._add_node(child_node, first, pcm, recurse_known=True) + + # Chevron when not yet listing children: unvisited (lazy), or visited with + # known edges not shown under this row (e.g. prior over-deep traverse). + if not self._has_real_children(first) and (not is_visited or has_children): + self._ensure_placeholder(first) + + def _resolve_meta(self, node) -> dict: + """Fetch activity metadata from bw2data (cached; empty dict on failure).""" + aid = getattr(node, "activity_datapackage_id", None) + if aid in self._meta_cache: + return self._meta_cache[aid] + try: + act = bd.get_node(id=aid) + meta = { + "product": act.get("reference product") or act.get("name", ""), + "name": act.get("name", ""), + "location": act.get("location", ""), + "database": act.get("database", ""), + "unit": act.get("unit", ""), + } + except Exception: + meta = {} + if aid is not None: + self._meta_cache[aid] = meta + return meta + + def _update_col_max(self, col: int, value: float) -> None: + if value > self.col_max.get(col, 0.0): + self.col_max[col] = value + + +# --------------------------------------------------------------------------- +# Sunburst plot (Ticket 05) +# --------------------------------------------------------------------------- + +class SunburstPlot(widgets.ABPlot): + """Layered donut chart showing the contribution tree by tier. + + Ring construction: one ring per tier (depth 1…plot_depth). Each wedge's + angular width = child.cumulative_score / parent.cumulative_score. An + "other" wedge fills the remainder where the traversal was pruned. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.plot_name = "Contribution Tree" + self._state: Optional[SameNodeEachVisitGraphTraversal] = None + self._total_score: float = 0.0 + self._plot_depth: int = 3 + + def set_state( + self, + state: SameNodeEachVisitGraphTraversal, + total_score: float, + plot_depth: int = 3, + ) -> None: + self._state = state + self._total_score = total_score + self._plot_depth = plot_depth + self.plot() + + def update_depth(self, plot_depth: int) -> None: + self._plot_depth = plot_depth + self.plot() + + def plot(self) -> None: + if self._state is None or self._total_score == 0.0: + self.figure.clear() + self.canvas.draw_idle() + return + + rings = build_sunburst_rings( + self._state.nodes, + self._state.edges, + self._total_score, + max_depth=self._plot_depth, + ) + if not rings: + self.figure.clear() + self.canvas.draw_idle() + return + + self.figure.clear() + ax = self.figure.add_subplot(111, polar=True) + ax.set_theta_zero_location("N") + ax.set_theta_direction(-1) + ax.set_axis_off() + + n_rings = len(rings) + ring_width = 1.0 / (n_rings + 1) # leave space for centre label + + import numpy as np + import matplotlib + + cmap = matplotlib.colormaps["tab20c"] + + for ring_idx, ring in enumerate(rings): + bottom = ring_width * (ring_idx + 1) + + # Track angular position for each parent + # We need to lay out wedges respecting parent arc positions. + # Build per-parent wedge lists + by_parent: dict = {} + for w in ring: + by_parent.setdefault(w["parent_unique_id"], []).append(w) + + # For tier-1 ring: parent is root, arc starts at 0, full circle + # For deeper rings: use parent wedge start angles (stored per uid) + if ring_idx == 0: + parent_starts = {list(by_parent.keys())[0]: 0.0} + parent_spans = {list(by_parent.keys())[0]: 2 * np.pi} + else: + parent_starts = getattr(self, "_wedge_starts", {}) + parent_spans = getattr(self, "_wedge_spans", {}) + + new_starts: dict = {} + new_spans: dict = {} + + for parent_uid, wedges in by_parent.items(): + p_start = parent_starts.get(parent_uid, 0.0) + p_span = parent_spans.get(parent_uid, 2 * np.pi) + + theta = p_start + for i, w in enumerate(wedges): + arc = w["share"] * p_span + colour = ( + (0.7, 0.7, 0.7, 0.5) + if w["is_other"] + else cmap((ring_idx * 7 + i) % 20 / 20) + ) + ax.bar( + x=theta, + width=arc, + bottom=bottom, + height=ring_width * 0.9, + color=colour, + edgecolor="white", + linewidth=0.5, + align="edge", + ) + if not w["is_other"] and arc > 0.2: + label = str(w.get("label", ""))[:20] + mid = theta + arc / 2 + ax.text( + mid, + bottom + ring_width * 0.45, + label, + ha="center", + va="center", + fontsize=6, + rotation=0, + clip_on=True, + ) + new_starts[w["unique_id"]] = theta + new_spans[w["unique_id"]] = arc + theta += arc + + self._wedge_starts = new_starts + self._wedge_spans = new_spans + + # Centre label + ax.text( + 0, 0, + f"Tier {self._plot_depth}", + ha="center", va="center", + fontsize=8, + transform=ax.transData, + ) + + self.finish_plot() + + +# --------------------------------------------------------------------------- +# Main tab widget (Tickets 04, 06, 07, 08, 09) +# --------------------------------------------------------------------------- + +class ContributionTreeTab(QtWidgets.QWidget): + """Contribution Tree tab for the LCA Results page. + + Shows a QTreeView (lazy, expandable by tier) with a sunburst plot above. + Cache key: (fu_index, method_index, scenario_index, cutoff_percent). + Each entry stores the Brightway traversal and the set of expanded row uids + so switching RF / IC / scenario restores both calculation and open branches. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.parent = parent + self.has_scenarios: bool = getattr(parent, "has_scenarios", False) + + # State + self._cache: dict[tuple, ContributionTreeCacheEntry] = {} + self._active_cache_key: tuple | None = None + self._current_state: Optional[SameNodeEachVisitGraphTraversal] = None + self._cached_lca: Optional[bc.LCA] = None + self.has_been_opened: bool = False + self.plot_name: str = "Contribution Tree" + # Skip lazy-fetch / leaf-collapse while applying Expand-to view state + self._suppress_expand_handler: bool = False + + # --- Controls --- + self.fu_cb = SmallComboBox() + self.method_cb = SmallComboBox() + self.scenario_cb = SmallComboBox() + self.scenario_label = QtWidgets.QLabel("Scenario:") + + # Graph-traversal cutoff as percent of total score (Brightway needs (0, 1)) + self.cutoff_sb = QtWidgets.QDoubleSpinBox() + self.cutoff_sb.setRange(0.001, 99.0) + self.cutoff_sb.setDecimals(3) + self.cutoff_sb.setSingleStep(0.01) + self.cutoff_sb.setValue(0.01) + self.cutoff_sb.setSuffix(" %") + self.cutoff_sb.setKeyboardTracking(False) + self.cutoff_sb.setToolTip( + "Graph traversal cutoff: prune branches whose cumulative impact " + "is below this percent of the total LCA score" + ) + + self.plot_depth_sb = QtWidgets.QSpinBox() + self.plot_depth_sb.setRange(1, 20) + self.plot_depth_sb.setValue(3) + self.plot_depth_sb.setToolTip("Number of tiers shown in the sunburst plot") + + self.expand_mode_cb = SmallComboBox() + self.expand_mode_cb.addItem("Tier", EXPAND_MODE_TIER) + self.expand_mode_cb.addItem("Individual path impact", EXPAND_MODE_PATH) + self.expand_mode_cb.addItem("Cumulative impact", EXPAND_MODE_CUMULATIVE) + self.expand_mode_cb.setToolTip("How far auto-expand calculates and opens the tree") + + self.expand_value_sb = QtWidgets.QDoubleSpinBox() + self.expand_value_sb.setKeyboardTracking(False) + self.expand_btn = QtWidgets.QPushButton("Expand") + self.expand_btn.setToolTip("Calculate and open branches according to the expand policy") + + self.show_plot_cb = QtWidgets.QCheckBox("Show plot") + self.show_plot_cb.setChecked(False) + self.show_table_cb = QtWidgets.QCheckBox("Show table") + self.show_table_cb.setChecked(True) + self._last_expand_target_pct: float | None = None + + # Export buttons + self.export_table_btn = QtWidgets.QPushButton("Export table…") + self.export_plot_btn = QtWidgets.QPushButton("Export plot…") + + self._stats_label = QtWidgets.QLabel("") + self._stats_label.setToolTip( + "Shown = visible rows / Direct impact (%) sum / deepest visible tier. " + "Calculated = traversal nodes / their coverage / deepest calculated tier." + ) + # --- Tree view --- + self._tree_model = ContributionTreeModel(self) + self._tree_view = QtWidgets.QTreeView() + self._tree_view.setModel(self._tree_model) + self._tree_view.setUniformRowHeights(False) + self._tree_view.setAlternatingRowColors(True) + self._tree_view.setSortingEnabled(False) + self._tree_view.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows + ) + self._tree_view.setSelectionMode( + QtWidgets.QAbstractItemView.SelectionMode.ExtendedSelection + ) + self._tree_view.setContextMenuPolicy( + QtCore.Qt.ContextMenuPolicy.CustomContextMenu + ) + self._tree_view.header().setStretchLastSection(False) + self._tree_view.header().setSectionResizeMode(COL_PROCESS, QtWidgets.QHeaderView.Stretch) + + # Impact-tint delegates: red = cumulative, blue = direct (% and absolute) + self._delegates: dict[int, ImpactBackgroundDelegate] = { + COL_CUMULATIVE_PCT: ImpactBackgroundDelegate( + column_max=100.0, + positive_rgb=(210, 85, 85), + parent=self._tree_view, + ), + COL_DIRECT_PCT: ImpactBackgroundDelegate( + column_max=100.0, + positive_rgb=(70, 130, 210), + parent=self._tree_view, + ), + COL_CUMULATIVE: ImpactBackgroundDelegate( + column_max=1.0, + positive_rgb=(210, 85, 85), + parent=self._tree_view, + ), + COL_DIRECT: ImpactBackgroundDelegate( + column_max=1.0, + positive_rgb=(70, 130, 210), + parent=self._tree_view, + ), + } + for col, d in self._delegates.items(): + self._tree_view.setItemDelegateForColumn(col, d) + + # --- Sunburst plot --- + self._plot = SunburstPlot(self) + self._plot.setMinimumHeight(180) + + self._tree_view.setMinimumHeight(120) + + # --- Splitter --- + self._splitter = QtWidgets.QSplitter(QtCore.Qt.Vertical) + self._splitter.addWidget(self._plot) + self._splitter.addWidget(self._tree_view) + self._splitter.setStretchFactor(0, 1) + self._splitter.setStretchFactor(1, 2) + self._splitter.setChildrenCollapsible(False) + + self._build_layout() + self._connect_signals() + self._apply_expand_mode_defaults(reset_value=True) + self._update_calculation_setup() + QtCore.QTimer.singleShot(0, self._update_view_visibility) + + def showEvent(self, event: QtGui.QShowEvent) -> None: + super().showEvent(event) + QtCore.QTimer.singleShot(0, self._apply_splitter_sizes) + + # ------------------------------------------------------------------ + # Plot / table visibility + # ------------------------------------------------------------------ + + @Slot() + def _update_view_visibility(self, *_args) -> None: + """Show or hide plot/table and redistribute splitter space.""" + show_plot = self.show_plot_cb.isChecked() + show_table = self.show_table_cb.isChecked() + self._plot.setVisible(show_plot) + self._tree_view.setVisible(show_table) + QtCore.QTimer.singleShot(0, self._apply_splitter_sizes) + + def _apply_splitter_sizes(self) -> None: + show_plot = self.show_plot_cb.isChecked() + show_table = self.show_table_cb.isChecked() + total = max(self._splitter.height(), 1) + if show_plot and show_table: + plot_h = max(total // 3, self._plot.minimumHeight()) + table_h = max(total - plot_h, self._tree_view.minimumHeight()) + self._splitter.setSizes([plot_h, table_h]) + elif show_plot: + self._splitter.setSizes([total, 0]) + elif show_table: + self._splitter.setSizes([0, total]) + if show_plot: + self._plot.sync_figure_to_widget() + self._plot.canvas.draw_idle() + + # ------------------------------------------------------------------ + # Layout + # ------------------------------------------------------------------ + + def _build_layout(self) -> None: + main = QtWidgets.QVBoxLayout(self) + main.setContentsMargins(4, 4, 4, 4) + + # Header + help + help_btn = lca_help_tool_button( + self, + "Left click for help on the Contribution Tree", + self._show_help, + ) + main.addLayout(lca_header_layout("Contribution Tree", help_btn)) + + # Control row 1: FU / method / scenario (left-aligned like other LCA tabs) + row1 = lca_tab_control_row() + row1.addWidget(QtWidgets.QLabel("Reference flow:")) + row1.addWidget(self.fu_cb) + row1.addWidget(QtWidgets.QLabel("Impact category:")) + row1.addWidget(self.method_cb) + row1.addWidget(self.scenario_label) + row1.addWidget(self.scenario_cb) + row1.addStretch() + main.addLayout(row1) + + # Control row 2: cutoff / plot tiers / expand policy + row2 = lca_tab_control_row() + row2.addWidget(QtWidgets.QLabel("Cutoff:")) + row2.addWidget(self.cutoff_sb) + row2.addSpacing(12) + row2.addWidget(QtWidgets.QLabel("Plot tiers:")) + row2.addWidget(self.plot_depth_sb) + row2.addSpacing(12) + row2.addWidget(QtWidgets.QLabel("Expand to:")) + row2.addWidget(self.expand_mode_cb) + row2.addWidget(self.expand_value_sb) + row2.addWidget(self.expand_btn) + row2.addSpacing(12) + row2.addWidget(self.show_plot_cb) + row2.addWidget(self.show_table_cb) + row2.addStretch() + main.addLayout(row2) + + # Splitter (plot + tree) + main.addWidget(self._splitter, 1) + + # Footer: stats + export + footer = QtWidgets.QHBoxLayout() + footer.addWidget(self._stats_label) + footer.addStretch() + footer.addWidget(self.export_table_btn) + footer.addWidget(self.export_plot_btn) + main.addLayout(footer) + + # ------------------------------------------------------------------ + # Signals + # ------------------------------------------------------------------ + + def _connect_signals(self) -> None: + self.fu_cb.currentIndexChanged.connect(self._on_selection_changed) + self.method_cb.currentIndexChanged.connect(self._on_selection_changed) + self.scenario_cb.currentIndexChanged.connect(self._on_selection_changed) + self.cutoff_sb.valueChanged.connect(self._on_cutoff_changed) + self.plot_depth_sb.valueChanged.connect(self._on_plot_depth_changed) + self.expand_mode_cb.currentIndexChanged.connect(self._on_expand_mode_changed) + self.expand_btn.clicked.connect(self._on_expand_clicked) + self.show_plot_cb.toggled.connect(self._update_view_visibility) + self.show_table_cb.toggled.connect(self._update_view_visibility) + # Queued so model mutations do not run inside QTreeView's expand stack + # (synchronous removeRow/appendRow there causes access violations on Windows). + self._tree_view.expanded.connect( + self._on_row_expanded, QtCore.Qt.ConnectionType.QueuedConnection + ) + self._tree_view.collapsed.connect(self._on_row_collapsed) + self._tree_view.customContextMenuRequested.connect(self._on_tree_context_menu) + self._tree_model.column_max_changed.connect(self._update_delegate_maxima) + self.export_table_btn.clicked.connect(self._export_table) + self.export_plot_btn.clicked.connect(self._export_plot) + app.application.theme_changed.connect(self.update_tab) + + # ------------------------------------------------------------------ + # Calculation-setup population + # ------------------------------------------------------------------ + + def update_tab(self) -> None: + """Called when the tab is shown or the theme changes. + + Guarded by ``has_been_opened`` so the generic ``_update_tabs`` loop + in ``LCAResultsPage`` does not trigger a traversal at construction + time (same pattern as Sankey / Tree Navigator). + ``has_been_opened`` is set to True by ``LCAResultsPage.generate_content_on_click`` + before this method is called on first open. + """ + if not self.has_been_opened: + return + if self._current_state is None: + self._run_traversal() + else: + self._save_view_snapshot() + uids = None + model_uids = None + if self._active_cache_key is not None and self._active_cache_key in self._cache: + entry = self._cache[self._active_cache_key] + uids = entry.expanded_uids + model_uids = entry.model_uids + self._reload_from_state(expanded_uids=uids, model_uids=model_uids) + + def configure_scenario(self) -> None: + configure_scenario_widgets( + has_scenarios=self.has_scenarios, + scenario_box=self.scenario_cb, + scenario_label=self.scenario_label, + parent=self.parent, + ) + + update_combobox = staticmethod(update_combobox) + + def _update_calculation_setup(self, cs_name: str = None) -> None: + for w in (self.fu_cb, self.method_cb, self.scenario_cb): + w.blockSignals(True) + + cs = cs_name or (self.parent.cs_name if self.parent else None) + if cs is None: + for w in (self.fu_cb, self.method_cb, self.scenario_cb): + w.blockSignals(False) + return + + import bw2data as _bd + setup = _bd.calculation_setups.get(cs, {}) + fu_acts = [ + list({_bd.get_activity(k): v for k, v in fu.items()}.keys())[0] + for fu in setup.get("inv", []) + ] + self.fu_cb.clear() + self.fu_cb.addItems([f"{repr(a)} | {a._data.get('database')}" for a in fu_acts]) + self.method_cb.clear() + self.method_cb.addItems([repr(m) for m in setup.get("ia", [])]) + self.configure_scenario() + + for w in (self.fu_cb, self.method_cb, self.scenario_cb): + w.blockSignals(False) + + # ------------------------------------------------------------------ + # Cache key + # ------------------------------------------------------------------ + + def _cache_key(self) -> tuple: + return ( + self.fu_cb.currentIndex(), + self.method_cb.currentIndex(), + self.scenario_cb.currentIndex() if self.has_scenarios else None, + round(self.cutoff_sb.value(), 6), + ) + + def _traversal_cutoff(self) -> float: + """UI cutoff is percent; Brightway expects a fraction in (0, 1).""" + return max(min(self.cutoff_sb.value() / 100.0, 0.999), 1e-12) + + def _selection_inputs(self, key: tuple): + """Resolve demand dict and method tuple for a cache key.""" + import bw2data as _bd + + fu_idx, method_idx, scenario_idx, _cutoff_pct = key + cs = self.parent.cs_name + setup = _bd.calculation_setups[cs] + demand_raw = setup["inv"][fu_idx] + method = setup["ia"][method_idx] + demand = {_bd.get_activity(k).id: v for k, v in demand_raw.items()} + return demand, method, scenario_idx + + def _ensure_lca(self, demand: dict, method, scenario_idx, method_idx: int | None = None) -> None: + """Keep the shared LCA object in sync with the current RF/method/scenario. + + All cached traversal states hold a reference to this same LCA. Switching + RF without ``redo_lci`` leaves ``state.lca.score`` belonging to another + demand, which breaks coverage checks and further ``traverse_from_node``. + """ + import bw2data as _bd + + if self.has_scenarios and scenario_idx is not None: + mi = method_idx if method_idx is not None else self.method_cb.currentIndex() + self.parent.mlca.update_lca_calculation_for_sankey( + scenario_idx, demand, mi + ) + + if self._cached_lca is None: + fu_input, data_objs, _ = _bd.prepare_lca_inputs(demand=demand, method=method) + self._cached_lca = bc.LCA(demand=fu_input, data_objs=data_objs) + self._cached_lca.lci(factorize=True) + self._cached_lca.lcia() + else: + self._cached_lca.redo_lci(demand) + self._cached_lca.switch_method(method) + self._cached_lca.lcia() + + @staticmethod + def _state_total_score(state: SameNodeEachVisitGraphTraversal) -> float: + meta = getattr(state, "metadata", None) or {} + if "total_score" in meta: + return float(meta["total_score"]) + return float(state.lca.score) + + @staticmethod + def _store_total_score(state: SameNodeEachVisitGraphTraversal) -> None: + if state.metadata is None: + state.metadata = {} + state.metadata["total_score"] = float(state.lca.score) + + # ------------------------------------------------------------------ + # Traversal + # ------------------------------------------------------------------ + + def _busy_dialog(self, label: str) -> QtWidgets.QProgressDialog: + """Indeterminate busy spinner (QProgressDialog with range 0–0).""" + progress = QtWidgets.QProgressDialog(label, None, 0, 0, self) + progress.setWindowTitle("Contribution Tree") + # Non-modal so tree expand/collapse still applies while it is visible. + progress.setWindowModality(QtCore.Qt.WindowModality.NonModal) + progress.setMinimumDuration(0) + progress.setCancelButton(None) + progress.setAttribute(QtCore.Qt.WidgetAttribute.WA_DeleteOnClose, False) + progress.show() + progress.raise_() + QtWidgets.QApplication.processEvents() + return progress + + @staticmethod + def _busy_tick(progress: QtWidgets.QProgressDialog, label: str | None = None) -> None: + if label is not None: + progress.setLabelText(label) + QtWidgets.QApplication.processEvents( + QtCore.QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents + ) + + def _run_traversal(self) -> None: + key = self._cache_key() + demand, method, scenario_idx = self._selection_inputs(key) + method_idx = key[1] + + if key in self._cache: + logger.debug(f"Contribution tree cache hit: {key}") + # Shared LCA may have been switched to another RF — restore it + # before using the cached graph (scores, further expand). + self._ensure_lca(demand, method, scenario_idx, method_idx) + entry = self._cache[key] + self._store_total_score(entry.state) + self._active_cache_key = key + self._current_state = entry.state + self._reload_from_state( + expanded_uids=entry.expanded_uids, + model_uids=entry.model_uids, + ) + self._fit_cumulative_column() + return + + logger.debug(f"Contribution tree traversal start: {key}") + progress = self._busy_dialog("Calculating contribution tree…") + + try: + import bw2data as _bd + + self._busy_tick(progress, "Running LCI / LCIA…") + self._ensure_lca(demand, method, scenario_idx, method_idx) + + self._busy_tick(progress, "Traversing supply chain…") + t0 = time.time() + state = SameNodeEachVisitGraphTraversal( + lca=self._cached_lca, + settings=GraphTraversalSettings(cutoff=self._traversal_cutoff()), + ) + with suppress_graph_traversal_warnings(): + state.traverse(depth=2) + state.metadata = {"unit": _bd.methods[method].get("unit", "")} + self._store_total_score(state) + logger.debug(f"Traversal done in {time.time()-t0:.2f}s") + + self._cache[key] = ContributionTreeCacheEntry(state=state) + self._active_cache_key = key + self._current_state = state + self._last_expand_target_pct = None + self._busy_tick(progress, "Building tree…") + self._reload_from_state(expanded_uids=None, model_uids=None) + self._save_view_snapshot() + self._fit_cumulative_column() + + except Exception as exc: + logger.exception("Contribution tree traversal failed") + QtWidgets.QMessageBox.warning( + self, + "Contribution Tree", + f"Traversal failed: {exc}", + ) + finally: + progress.close() + progress.deleteLater() + + def _reload_from_state( + self, + expanded_uids: set[int] | None = None, + model_uids: set[int] | None = None, + ) -> None: + """Rebuild the tree model and plot from the current cached state. + + ``model_uids`` restores a filtered model (e.g. after path prune). + ``expanded_uids`` restores which branches were open. + """ + state = self._current_state + if state is None: + return + total = self._state_total_score(state) + self._tree_model.load_state(state, total) + if model_uids is not None: + self._tree_model.restrict_to_uids(model_uids) + self._update_delegate_maxima() + self._tree_view.collapseAll() + if expanded_uids: + self._restore_expanded_uids(expanded_uids) + self._reload_plot() + self._update_footer_stats() + QtCore.QTimer.singleShot(0, self._apply_splitter_sizes) + + def _collect_expanded_uids(self) -> set[int]: + """Return unique_ids of rows currently expanded in the tree view.""" + expanded: set[int] = set() + for uid, item in self._tree_model._uid_to_item.items(): + idx = self._tree_model.indexFromItem(item) + if idx.isValid() and self._tree_view.isExpanded(idx): + expanded.add(uid) + return expanded + + def _restore_expanded_uids(self, uids: set[int]) -> None: + """Open cached branches (parents before children). Skips missing uids.""" + if not uids: + return + self._suppress_expand_handler = True + try: + to_expand: list[tuple[int, QtGui.QStandardItem]] = [] + for uid in uids: + item = self._tree_model._uid_to_item.get(uid) + if item is None or not self._tree_model._has_real_children(item): + continue + to_expand.append((int(item.data(TIER_ROLE) or 0), item)) + to_expand.sort(key=lambda pair: pair[0]) + for _, item in to_expand: + idx = self._tree_model.indexFromItem(item) + if idx.isValid(): + self._tree_view.expand(idx) + finally: + self._suppress_expand_handler = False + + def _save_view_snapshot(self) -> None: + """Persist model rows + expanded branches for the active cache key.""" + key = self._active_cache_key + if key is None or key not in self._cache: + return + entry = self._cache[key] + entry.expanded_uids = self._collect_expanded_uids() + entry.model_uids = set(self._tree_model._uid_to_item.keys()) + + # ------------------------------------------------------------------ + # Slot handlers + # ------------------------------------------------------------------ + + @Slot() + def _on_selection_changed(self) -> None: + self._save_view_snapshot() + self._current_state = None + self._active_cache_key = None + self._run_traversal() + + @Slot() + def _on_cutoff_changed(self) -> None: + self._save_view_snapshot() + self._current_state = None + self._active_cache_key = None + self._run_traversal() + + @Slot(int) + def _on_plot_depth_changed(self, depth: int) -> None: + self._reload_plot() + + @Slot() + def _on_expand_mode_changed(self) -> None: + self._apply_expand_mode_defaults(reset_value=True) + + def _apply_expand_mode_defaults(self, *, reset_value: bool) -> None: + mode = self.expand_mode_cb.currentData() + self.expand_value_sb.blockSignals(True) + if mode == EXPAND_MODE_TIER: + self.expand_value_sb.setDecimals(0) + self.expand_value_sb.setRange(1, 20) + self.expand_value_sb.setSingleStep(1) + self.expand_value_sb.setSuffix("") + if reset_value: + self.expand_value_sb.setValue(3) + self.expand_value_sb.setToolTip("Maximum tier to open") + elif mode == EXPAND_MODE_PATH: + self.expand_value_sb.setDecimals(1) + self.expand_value_sb.setRange(0.1, 100.0) + self.expand_value_sb.setSingleStep(0.5) + self.expand_value_sb.setSuffix(" %") + if reset_value: + self.expand_value_sb.setValue(1.0) + self.expand_value_sb.setToolTip( + "Auto-expand nodes whose path impact is at least this % of total; " + "siblings below that % are still listed (engine cutoff still applies)" + ) + else: + self.expand_value_sb.setDecimals(0) + self.expand_value_sb.setRange(1, 99) + self.expand_value_sb.setSingleStep(1) + self.expand_value_sb.setSuffix(" %") + if reset_value: + self.expand_value_sb.setValue(60) + self.expand_value_sb.setToolTip( + "Largest-first expand until direct-impact coverage reaches this % (max 99)" + ) + self.expand_value_sb.blockSignals(False) + + @Slot() + def _on_expand_clicked(self) -> None: + if self._current_state is None: + return + mode = self.expand_mode_cb.currentData() + value = float(self.expand_value_sb.value()) + state = self._current_state + # Ensure shared LCA matches this cached graph before any further traverse. + key = self._cache_key() + demand, method, scenario_idx = self._selection_inputs(key) + self._ensure_lca(demand, method, scenario_idx, key[1]) + self._store_total_score(state) + total = self._state_total_score(state) + root_uid = state._root_node.unique_id + + progress = self._busy_dialog("Expanding contribution tree…") + self._last_expand_target_pct = ( + value if mode in (EXPAND_MODE_PATH, EXPAND_MODE_CUMULATIVE) else None + ) + try: + cumulative_included: set[int] | None = None + cumulative_expand: set[int] | None = None + path_included: set[int] | None = None + path_expand: set[int] | None = None + + if mode == EXPAND_MODE_CUMULATIVE: + self._busy_tick(progress, "Traversing supply chain…") + cumulative_included, cumulative_expand = ( + self._expand_cumulative_brightway(value, progress) + ) + else: + self._busy_tick(progress, "Traversing supply chain…") + self._expand_policy_brightway(mode, value, progress) + if mode == EXPAND_MODE_PATH: + path_included, path_expand = path_display_set( + state.nodes, + state.edges, + root_uid, + total, + value, + state.visited_nodes, + ) + + # Rebuild tree once from traversal state (already-known nodes are free) + self._busy_tick(progress, "Building tree…") + self._tree_model.load_state(state, total) + if mode == EXPAND_MODE_PATH and path_included is not None: + # Keep high-path nodes + all their siblings; drop unrelated deep cache + self._tree_model.restrict_to_uids(path_included) + elif mode == EXPAND_MODE_CUMULATIVE and cumulative_included is not None: + # Show only the largest-first set that meets the target — not + # every node ever calculated in this RF's cached graph. + self._tree_model.restrict_to_uids(cumulative_included) + + self._busy_tick(progress, "Updating tree view…") + self._update_delegate_maxima() + if mode == EXPAND_MODE_TIER: + self._apply_expand_view_state(max_tier=int(value)) + elif mode == EXPAND_MODE_PATH and path_expand is not None: + self._restore_expanded_uids(path_expand) + elif cumulative_expand is not None: + self._restore_expanded_uids(cumulative_expand) + if self.show_plot_cb.isChecked(): + self._busy_tick(progress, "Updating plot…") + self._reload_plot() + self._update_footer_stats() + self._fit_cumulative_column() + self._save_view_snapshot() + finally: + progress.close() + progress.deleteLater() + + def _expand_cumulative_brightway( + self, + target_pct: float, + progress: QtWidgets.QProgressDialog, + ) -> tuple[set[int], set[int]]: + """Largest-first from RFs until display-set coverage meets ``target_pct``. + + Reuses already-calculated edges when possible; only calls + ``traverse_from_node`` when the next node to open is still unvisited. + Returns ``(included_uids, visually_expanded_uids)``. + """ + state = self._current_state + assert state is not None + total = self._state_total_score(state) + root_uid = state._root_node.unique_id + failed: set[int] = set() + included: set[int] = set() + to_expand: set[int] = set() + + for step in range(10_000): + included, to_expand, need = plan_cumulative_expand( + state.nodes, + state.edges, + root_uid, + total, + target_pct, + state.visited_nodes, + exclude=failed, + ) + if need is None: + break + if step % 10 == 0: + self._busy_tick( + progress, + f"Traversing supply chain… ({len(state.nodes)} nodes)", + ) + node = state.nodes.get(need) + if node is None or need in state.visited_nodes: + failed.add(need) + continue + node.depth = 0 + with suppress_graph_traversal_warnings(): + if not state.traverse_from_node(need, depth=1): + failed.add(need) + + return included, to_expand + + def _expand_policy_brightway( + self, + mode: str, + value: float, + progress: QtWidgets.QProgressDialog, + ) -> None: + """Run tier/path expand policy against Brightway state only. + + Cumulative mode uses :meth:`_expand_cumulative_brightway` instead. + """ + state = self._current_state + if state is None: + return + total = self._state_total_score(state) + root_uid = state._root_node.unique_id + failed: set[int] = set() + + for step in range(10_000): + candidates = next_expand_candidates( + state.nodes, + state.edges, + state.visited_nodes, + mode=mode, + value=value, + total_score=total, + root_uid=root_uid, + exclude=failed, + ) + if not candidates: + break + + if step % 10 == 0: + self._busy_tick( + progress, + f"Traversing supply chain… ({len(state.nodes)} nodes)", + ) + + made_progress = False + for uid in candidates: + node = state.nodes.get(uid) + if node is None or uid in state.visited_nodes: + failed.add(uid) + continue + node.depth = 0 + with suppress_graph_traversal_warnings(): + if state.traverse_from_node(uid, depth=1): + made_progress = True + else: + failed.add(uid) + if not made_progress: + break + + def _apply_expand_view_state( + self, + max_tier: int | None = None, + *, + min_path_pct: float | None = None, + only_visited: bool = False, + ) -> None: + """Collapse, then open calculated branches according to the expand policy. + + * Tier: open rows with real children whose display tier is ``< max_tier``. + * Individual path impact: open only nodes whose path impact still meets + the threshold (after pruning smaller branches). + * Cumulative: open visited nodes that have real children. + """ + state = self._current_state + total = self._state_total_score(state) if state is not None else 0.0 + visited = state.visited_nodes if state is not None else set() + + self._suppress_expand_handler = True + try: + self._tree_view.collapseAll() + to_expand: list[tuple[int, QtGui.QStandardItem]] = [] + for uid, item in self._tree_model._uid_to_item.items(): + if not self._tree_model._has_real_children(item): + continue + tier = item.data(TIER_ROLE) + tier_i = int(tier) if tier is not None else 0 + if max_tier is not None and tier_i >= max_tier: + continue + if only_visited and uid not in visited: + continue + if min_path_pct is not None and state is not None: + node = state.nodes.get(uid) + if node is None: + continue + if abs(cumulative_percent(node, total)) < min_path_pct: + continue + to_expand.append((tier_i, item)) + to_expand.sort(key=lambda pair: pair[0]) + for _, item in to_expand: + idx = self._tree_model.indexFromItem(item) + if idx.isValid(): + self._tree_view.expand(idx) + finally: + self._suppress_expand_handler = False + + @Slot() + def _show_help(self) -> None: + QtWidgets.QMessageBox.question( + self, + "Contribution Tree", + HELP_TEXT.strip(), + QtWidgets.QMessageBox.Ok, + QtWidgets.QMessageBox.Ok, + ) + + def _fit_cumulative_column(self) -> None: + """Widen Cumulative impact (%) to fit header, values, and tree indentation.""" + self._tree_view.resizeColumnToContents(COL_CUMULATIVE_PCT) + + @Slot(QtCore.QPoint) + def _on_tree_context_menu(self, pos: QtCore.QPoint) -> None: + index = self._tree_view.indexAt(pos) + if index.isValid(): + # Right-click on an unselected row selects it (standard table UX) + sm = self._tree_view.selectionModel() + if sm is not None and not sm.isSelected(index): + sm.select( + index, + QtCore.QItemSelectionModel.SelectionFlag.ClearAndSelect + | QtCore.QItemSelectionModel.SelectionFlag.Rows, + ) + + activities = self._selected_activities() + menu = QtWidgets.QMenu(self._tree_view) + menu.addAction( + app.actions.ActivityOpen.get_QAction( + activities, + parent=menu, + text="Open process" if len(activities) <= 1 else "Open processes", + enabled=bool(activities), + ) + ) + menu.exec_(self._tree_view.viewport().mapToGlobal(pos)) + + def _selected_activities(self) -> list: + """Brightway nodes for the currently selected contribution-tree rows.""" + state = self._current_state + if state is None: + return [] + sm = self._tree_view.selectionModel() + if sm is None: + return [] + seen: set[int] = set() + activities = [] + for index in sm.selectedRows(0): + item = self._tree_model.itemFromIndex(index) + if item is None: + continue + uid = item.data(UID_ROLE) + if uid is None or uid in seen: + continue + seen.add(uid) + node = state.nodes.get(uid) + if node is None: + continue + aid = getattr(node, "activity_datapackage_id", None) + if aid is None or aid < 0: + continue + try: + activities.append(bd.get_node(id=aid)) + except Exception: + logger.debug(f"Could not resolve activity id {aid} for Open process") + return activities + + @Slot(QtCore.QModelIndex) + def _on_row_expanded(self, index: QtCore.QModelIndex) -> None: + """Lazily traverse when the user expands a row (queued; safe to mutate model).""" + if self._suppress_expand_handler: + return + if not index.isValid(): + return + first_col_idx = index.siblingAtColumn(0) + if not first_col_idx.isValid(): + return + first_col_item = self._tree_model.itemFromIndex(first_col_idx) + if first_col_item is None: + return + uid = first_col_item.data(UID_ROLE) + if uid is None: + return + + # Materialize any known children not yet in the model (path prune / + # cumulative "only as many siblings as needed" leftovers). Safe when + # the row already has some children — collapse/re-expand reveals rest. + added = self._tree_model.expand_node(uid) + if added or self._tree_model._has_real_children(first_col_item): + self._update_delegate_maxima() + self._reload_plot() + self._update_footer_stats() + self._fit_cumulative_column() + self._save_view_snapshot() + return + + # Leaf: collapse after the current event finishes + persistent = QtCore.QPersistentModelIndex(first_col_idx) + + def _collapse_leaf(): + if persistent.isValid(): + self._tree_view.collapse(QtCore.QModelIndex(persistent)) + + QtCore.QTimer.singleShot(0, _collapse_leaf) + + @Slot(QtCore.QModelIndex) + def _on_row_collapsed(self, index: QtCore.QModelIndex) -> None: + """Footer coverage is view-scoped; refresh when branches hide.""" + if self._suppress_expand_handler: + return + self._update_footer_stats() + self._save_view_snapshot() + + # ------------------------------------------------------------------ + # Plot helpers + # ------------------------------------------------------------------ + + def _reload_plot(self) -> None: + if self._current_state is None: + return + if not self.show_plot_cb.isChecked(): + return + self._plot.set_state( + self._current_state, + self._state_total_score(self._current_state), + self.plot_depth_sb.value(), + ) + + # ------------------------------------------------------------------ + # Delegate maxima + # ------------------------------------------------------------------ + + @Slot() + def _update_delegate_maxima(self) -> None: + for col in BAR_COLUMNS: + mx = self._tree_model.col_max.get(col, 1.0) + if col in self._delegates: + self._delegates[col].column_max = max(mx, 1e-12) + self._tree_view.viewport().update() + + # ------------------------------------------------------------------ + # Footer stats + # ------------------------------------------------------------------ + + def _update_footer_stats(self) -> None: + state = self._current_state + if state is None: + self._stats_label.setText("") + return + root_uid = self._tree_model._root_uid + total = self._state_total_score(state) + shown_cov = self._visible_direct_impact_coverage() + shown_n = sum( + 1 + for item in self._tree_model._uid_to_item.values() + if self._is_row_visible(item) + ) + calc_cov = direct_impact_coverage(state.nodes, total, root_uid) + calc_n = sum(1 for uid in state.nodes if uid != root_uid) + shown_tier = self._max_visible_tier() + calc_tier = self._max_calculated_tier() + + calc_part = ( + f"Calculated: {calc_n} nodes, {calc_cov * 100:.1f}% of direct impacts, " + f"max tier {calc_tier}" + ) + target = self._last_expand_target_pct + if ( + target is not None + and self.expand_mode_cb.currentData() == EXPAND_MODE_CUMULATIVE + and calc_cov * 100.0 + 0.05 < target + ): + calc_part += f" (target {target:.0f}% — not reached)" + + self._stats_label.setText( + f"Shown: {shown_n} nodes, {shown_cov * 100:.1f}% of direct impacts, " + f"max tier {shown_tier}" + f" | {calc_part}" + ) + + def _is_row_visible(self, item: QtGui.QStandardItem) -> bool: + """True when every ancestor index is expanded in the tree view.""" + idx = self._tree_model.indexFromItem(item) + if not idx.isValid(): + return False + parent = idx.parent() + while parent.isValid(): + if not self._tree_view.isExpanded(parent): + return False + parent = parent.parent() + return True + + def _visible_direct_impact_coverage(self) -> float: + """Σ(direct impact of visible rows) / |total| — same as summing the column.""" + state = self._current_state + if state is None: + return 0.0 + total = self._state_total_score(state) + if total == 0.0: + return 0.0 + direct_sum = 0.0 + for uid, item in self._tree_model._uid_to_item.items(): + if not self._is_row_visible(item): + continue + node = state.nodes.get(uid) + if node is None: + continue + direct_sum += getattr(node, "direct_emissions_score", 0.0) + return direct_sum / abs(total) + + def _max_visible_tier(self) -> int: + """Deepest tier among rows whose ancestor chain is expanded in the view.""" + max_tier = 0 + for item in self._tree_model._uid_to_item.values(): + if self._is_row_visible(item): + max_tier = max(max_tier, int(item.data(TIER_ROLE) or 0)) + return max_tier + + def _max_calculated_tier(self) -> int: + """Deepest edge-based display tier among all discovered traversal nodes.""" + state = self._current_state + if state is None: + return 0 + root_uid = self._tree_model._root_uid + if root_uid is None: + return 0 + tiers = compute_node_tiers(state.nodes, state.edges, root_uid) + return max(tiers.values(), default=0) + + # ------------------------------------------------------------------ + # Export (Ticket 06) + # ------------------------------------------------------------------ + + @Slot() + def _export_table(self) -> None: + if self._current_state is None: + return + df = self._tree_model.to_dataframe(metadata_lookup=None) + if df.empty: + return + default_name = ( + lca_export_basename(self.parent.cs_name, "ContributionTree") + if self.parent + else "contribution_tree" + ) + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Export Contribution Tree", + default_name, + "Excel (*.xlsx);;CSV (*.csv)", + ) + if not path: + return + try: + if path.endswith(".csv"): + df.to_csv(path, index=False) + else: + df.to_excel(path, index=False) + logger.info(f"Contribution tree exported to {path}") + except Exception as exc: + QtWidgets.QMessageBox.warning(self, "Export failed", str(exc)) + + @Slot() + def _export_plot(self) -> None: + if self._current_state is None: + return + default_name = ( + lca_export_basename(self.parent.cs_name, "ContributionTreePlot") + if self.parent + else "contribution_tree_plot" + ) + path, _ = QtWidgets.QFileDialog.getSaveFileName( + self, + "Export Sunburst Plot", + default_name, + "SVG (*.svg);;PNG (*.png)", + ) + if not path: + return + try: + self._plot.figure.savefig(path, bbox_inches="tight") + logger.info(f"Contribution tree plot exported to {path}") + except Exception as exc: + QtWidgets.QMessageBox.warning(self, "Export failed", str(exc)) diff --git a/activity_browser/bwutils/contribution_tree.py b/activity_browser/bwutils/contribution_tree.py new file mode 100644 index 000000000..3f8d684cd --- /dev/null +++ b/activity_browser/bwutils/contribution_tree.py @@ -0,0 +1,645 @@ +"""Pure data-preparation helpers for the Contribution Tree tab. + +These functions transform a ``SameNodeEachVisitGraphTraversal`` state object +(or any object with compatible ``.nodes`` and ``.edges`` attributes) into the +data structures consumed by the tree item model, the sunburst plot, and the +table export. No Qt dependency — fully testable with plain fake objects. +""" + +from __future__ import annotations + +import warnings +from contextlib import contextmanager +from typing import TYPE_CHECKING, Callable + +import pandas as pd + +if TYPE_CHECKING: + pass + + +# --------------------------------------------------------------------------- +# Type aliases (kept simple to avoid Qt / bw imports at module level) +# --------------------------------------------------------------------------- + +NodeId = int # unique_id of a traversal Node + + +@contextmanager +def suppress_graph_traversal_warnings(): + """Silence bw_graph_tools coverage ``UserWarning``s (coverage is shown in the UI).""" + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=r"Graph traversal covered only.*", + category=UserWarning, + ) + yield + + +# --------------------------------------------------------------------------- +# Parent-child map +# --------------------------------------------------------------------------- + +def build_parent_child_map(nodes: dict, edges: list) -> dict[NodeId, list[NodeId]]: + """Return a mapping of parent unique_id → list of child unique_ids. + + The functional-unit root node (unique_id < 0 by convention) is included as + a key even when it has no children. + + Parameters + ---------- + nodes: + ``state.nodes`` dict — keys are ``unique_id`` integers. + edges: + ``state.edges`` list — each edge has ``.consumer_unique_id`` (parent) + and ``.producer_unique_id`` (child). + """ + children: dict[NodeId, list[NodeId]] = {uid: [] for uid in nodes} + for edge in edges: + parent = edge.consumer_unique_id + child = edge.producer_unique_id + if parent in children: + if child not in children[parent]: + children[parent].append(child) + # Ensure the child key exists even if not yet in nodes dict + children.setdefault(child, []) + return children + + +# --------------------------------------------------------------------------- +# Percentage helpers +# --------------------------------------------------------------------------- + +def cumulative_percent(node, total_score: float) -> float: + """Return node.cumulative_score / total_score * 100, or 0.0 on zero total.""" + if total_score == 0.0: + return 0.0 + return node.cumulative_score / total_score * 100.0 + + +def direct_percent(node, total_score: float) -> float: + """Return node.direct_emissions_score / total_score * 100, or 0.0 on zero total.""" + if total_score == 0.0: + return 0.0 + return node.direct_emissions_score / total_score * 100.0 + + +def compute_node_tiers( + nodes: dict, + edges: list, + root_uid: NodeId, +) -> dict[NodeId, int]: + """Assign display tier by graph distance from the functional-unit root. + + The virtual demand node (``root_uid``) is omitted. Its direct children — + the reference-flow activities — are **tier 0**; their suppliers are + tier 1; and so on. This matches practitioner language ("tier-1 suppliers" + = first inputs to the reference flow) and ``CONTEXT.md``. + + Do **not** use ``node.depth`` from ``bw_graph_tools`` after lazy + ``traverse_from_node`` calls — that API resets the traversed node's depth + to 0, so children are incorrectly labelled. + """ + if root_uid not in nodes: + return {} + pcm = build_parent_child_map(nodes, edges) + raw: dict[NodeId, int] = {root_uid: 0} + queue: list[NodeId] = [root_uid] + while queue: + uid = queue.pop(0) + for child_uid in pcm.get(uid, []): + if child_uid in nodes and child_uid not in raw: + raw[child_uid] = raw[uid] + 1 + queue.append(child_uid) + # Shift: hide virtual root; reference flows become tier 0 + return {uid: tier - 1 for uid, tier in raw.items() if uid != root_uid} + + +# --------------------------------------------------------------------------- +# Expand policy / footer stats +# --------------------------------------------------------------------------- + +EXPAND_MODES = ("tier", "path", "cumulative") + + +def _visible_contribution_nodes( + nodes: dict, + root_uid: NodeId | None = None, +) -> list: + """Nodes shown in the contribution tree (skip the virtual demand root). + + Do not filter on Brightway ``node.depth`` — it is mutated to 0 before + ``traverse_from_node`` and is not a reliable visibility signal. + """ + if root_uid is not None: + return [n for n in nodes.values() if n.unique_id != root_uid] + return list(nodes.values()) + + +def direct_impact_coverage( + nodes: dict, + total_score: float, + root_uid: NodeId | None = None, +) -> float: + """Σ(direct impact of visible nodes) / |total score|. + + Returns 0.0 when ``total_score`` is zero or there are no visible nodes. + """ + if not nodes or total_score == 0.0: + return 0.0 + direct_sum = sum( + getattr(n, "direct_emissions_score", 0.0) + for n in _visible_contribution_nodes(nodes, root_uid) + ) + return direct_sum / abs(total_score) + + +def coverage_of_uids( + nodes: dict, + uids: set[NodeId], + total_score: float, +) -> float: + """Σ(direct impact of ``uids``) / |total score|.""" + if not uids or total_score == 0.0: + return 0.0 + direct_sum = sum( + getattr(nodes[uid], "direct_emissions_score", 0.0) + for uid in uids + if uid in nodes + ) + return direct_sum / abs(total_score) + + +def plan_cumulative_expand( + nodes: dict, + edges: list, + root_uid: NodeId, + total_score: float, + target_pct: float, + visited: set, + *, + exclude: set | None = None, +) -> tuple[set[NodeId], set[NodeId], NodeId | None]: + """Largest-first cumulative expand plan starting from reference flows. + + Opens included nodes with the largest **remaining upstream** impact + (|cumulative| − |direct|). A node that is already almost entirely direct + (little upstream left) is not auto-opened — its tiny children stay hidden + until manual expand. When a node is opened, children are added + largest-first until Σ(direct of included) / |total| reaches ``target_pct``. + + Returns + ------- + included: + Row unique_ids that should be in the tree model. + to_expand: + Unique_ids that should be visually expanded (opened during the walk). + need_traverse: + If not ``None``, this unvisited uid must be ``traverse_from_node``'d + before the plan can continue; call again after traversing. + """ + failed = exclude or set() + pcm = build_parent_child_map(nodes, edges) + included: set[NodeId] = { + uid for uid in pcm.get(root_uid, []) if uid in nodes + } + walk_expanded: set[NodeId] = set() + target = target_pct / 100.0 + abs_total = abs(total_score) + + def _coverage() -> float: + return coverage_of_uids(nodes, included, total_score) + + def _remaining(n) -> float: + cum = abs(getattr(n, "cumulative_score", 0.0)) + direct = abs(getattr(n, "direct_emissions_score", 0.0)) + return max(cum - direct, 0.0) + + def _worth_opening(n) -> bool: + """Skip nodes whose impact is already almost all direct (dust upstream).""" + rem = _remaining(n) + if rem <= 0: + return False + cum = abs(getattr(n, "cumulative_score", 0.0)) + if cum > 0 and rem / cum < 0.05: + return False + if abs_total > 0 and rem / abs_total < 1e-9: + return False + return True + + def _can_open(uid: NodeId) -> bool: + if uid in failed: + return False + node = nodes.get(uid) + if node is None or not _worth_opening(node): + return False + if uid not in visited: + return True + return any(cid not in included for cid in pcm.get(uid, [])) + + def _add_children_until_target(parent_uid: NodeId) -> None: + """Add parent children largest-first; stop once coverage meets target.""" + children = [ + nodes[cid] + for cid in pcm.get(parent_uid, []) + if cid in nodes and cid not in included + ] + children.sort( + key=lambda n: (_remaining(n), abs(getattr(n, "cumulative_score", 0.0)), -n.unique_id), + reverse=True, + ) + for child in children: + included.add(child.unique_id) + if _coverage() >= target: + return + + while _coverage() < target: + candidates = [ + nodes[uid] + for uid in included + if uid in nodes and uid not in walk_expanded and _can_open(uid) + ] + if not candidates: + break + pick = max( + candidates, + key=lambda n: (_remaining(n), abs(getattr(n, "cumulative_score", 0.0)), -n.unique_id), + ) + uid = pick.unique_id + if uid not in visited: + return included, walk_expanded, uid + + walk_expanded.add(uid) + _add_children_until_target(uid) + + return included, walk_expanded, None + + +def tree_stats( + nodes: dict, + total_score: float, + *, + root_uid: NodeId | None = None, + edges: list | None = None, +) -> dict: + """Return ``node_count``, ``coverage``, and ``max_tier`` for visible nodes. + + ``max_tier`` uses edge-based display tiers when ``root_uid`` and ``edges`` + are provided — never Brightway's mutable ``node.depth``. + """ + visible = _visible_contribution_nodes(nodes, root_uid) + if root_uid is not None and edges is not None: + tiers = compute_node_tiers(nodes, edges, root_uid) + max_tier = max((tiers.get(n.unique_id, 0) for n in visible), default=0) + else: + max_tier = max((getattr(n, "depth", 0) for n in visible), default=0) + return { + "node_count": len(visible), + "coverage": direct_impact_coverage(nodes, total_score, root_uid), + "max_tier": max_tier, + } + + +def next_expand_candidates( + nodes: dict, + edges: list, + visited: set, + *, + mode: str, + value: float, + total_score: float, + root_uid: NodeId | None = None, + exclude: set | None = None, + eligible_ids: set | None = None, +) -> list[NodeId]: + """Return unique_ids that the expand policy should open next. + + Parameters + ---------- + mode: + ``"tier"``, ``"path"``, or ``"cumulative"``. + value: + For tier: maximum tier (int). For path / cumulative: percent + of |total| (0–100 UI scale). + visited: + ``state.visited_nodes`` — nodes already traversed. + root_uid: + Functional-unit node id; required for reliable tier mode (avoids + trusting mutated ``node.depth``). + exclude: + Ids to skip (e.g. already failed to expand). + eligible_ids: + If set, only consider these ids (e.g. rows already in the tree model). + Important for cumulative mode so we do not pick a globally largest + unvisited node that is not yet in the view and cannot be expanded. + + Returns + ------- + For ``tier`` / ``path``: all matching unvisited ids (any order). + For ``cumulative``: at most one id (largest abs cumulative among + unvisited), or ``[]`` when coverage already meets the target or nothing + remains to expand. + """ + if mode not in EXPAND_MODES: + raise ValueError(f"Unknown expand mode: {mode!r}") + + skip = exclude or set() + unvisited = [ + n for n in nodes.values() + if n.unique_id not in visited + and n.unique_id not in skip + and (eligible_ids is None or n.unique_id in eligible_ids) + ] + + if mode == "tier": + max_tier = int(value) + if root_uid is None: + # Fallback: prefer original depth-0 FU id if still unique + roots = [n for n in nodes.values() if getattr(n, "depth", None) == 0] + root_uid = roots[0].unique_id if len(roots) == 1 else None + if root_uid is None: + return [] + tiers = compute_node_tiers(nodes, edges, root_uid) + return [ + n.unique_id + for n in unvisited + if tiers.get(n.unique_id, max_tier + 1) < max_tier + ] + + if mode == "path": + if total_score == 0.0: + return [] + threshold = abs(total_score) * (value / 100.0) + return [ + n.unique_id + for n in unvisited + if abs(getattr(n, "cumulative_score", 0.0)) >= threshold + ] + + # cumulative — one step, largest-first among eligible unvisited + coverage = direct_impact_coverage(nodes, total_score, root_uid) + if coverage >= (value / 100.0) or not unvisited: + return [] + best = max( + unvisited, + key=lambda n: ( + abs(getattr(n, "cumulative_score", 0.0)), + -n.unique_id, + ), + ) + return [best.unique_id] + + +def path_display_set( + nodes: dict, + edges: list, + root_uid: NodeId, + total_score: float, + min_path_pct: float, + visited: set, +) -> tuple[set[NodeId], set[NodeId]]: + """Rows to show / expand for individual path-impact policy. + + * Auto-expand a node only if its path impact is ≥ ``min_path_pct`` **and** + it has at least one child that is also ≥ ``min_path_pct`` (the high-impact + path continues). Terminal high-impact nodes stay collapsed. + * Under each auto-expanded node, list **all** discovered children (including + below-threshold siblings). The engine cutoff already limits which children + exist in ``edges``. + + Returns ``(included_uids, visually_expanded_uids)``. + """ + pcm = build_parent_child_map(nodes, edges) + threshold = abs(total_score) * (min_path_pct / 100.0) if total_score else 0.0 + + def _above(uid: NodeId) -> bool: + node = nodes.get(uid) + if node is None: + return False + return abs(getattr(node, "cumulative_score", 0.0)) >= threshold + + high_path = { + uid + for uid, node in nodes.items() + if uid != root_uid + and uid in visited + and _above(uid) + } + + # Expand only while the high-impact path continues into a child + to_expand = { + uid + for uid in high_path + if any(_above(cid) for cid in pcm.get(uid, [])) + } + + included: set[NodeId] = { + uid for uid in pcm.get(root_uid, []) if uid in nodes + } + included.update(high_path) + for uid in to_expand: + for cid in pcm.get(uid, []): + if cid in nodes: + included.add(cid) + + return included, to_expand + + +# --------------------------------------------------------------------------- +# Sunburst ring builder +# --------------------------------------------------------------------------- + +def build_sunburst_rings( + nodes: dict, + edges: list, + total_score: float, + max_depth: int, +) -> list[list[dict]]: + """Build per-depth ring data for a sunburst (layered donut) chart. + + Returns a list of rings, one per depth level from 1 to ``max_depth``. + Each ring is a list of wedge dicts:: + + { + "unique_id": int, + "label": str, # activity name or "other" + "share": float, # fraction of *parent* arc (0–1) + "cumulative_score": float, + "is_other": bool, + } + + Wedge ``share`` is ``node.cumulative_score / parent.cumulative_score``. + An ``"other"`` wedge is appended when the children's shares don't sum to 1. + + Parameters + ---------- + nodes: + ``state.nodes`` dict. + edges: + ``state.edges`` list. + total_score: + ``lca.score`` — used only to guard against zero; not used for ring math. + max_depth: + Maximum tier depth to include (inclusive). + """ + if not nodes or total_score == 0.0: + return [] + + parent_child = build_parent_child_map(nodes, edges) + + # Collect nodes by depth + by_depth: dict[int, list] = {} + for node in nodes.values(): + d = node.depth + if 1 <= d <= max_depth: + by_depth.setdefault(d, []).append(node) + + rings: list[list[dict]] = [] + + for depth in range(1, max_depth + 1): + depth_nodes = by_depth.get(depth, []) + if not depth_nodes: + break + + # Group by parent to compute "other" wedge per parent + by_parent: dict[NodeId, list] = {} + for node in depth_nodes: + # Find this node's parent via edges + parent_id = _find_parent(node.unique_id, edges) + by_parent.setdefault(parent_id, []).append(node) + + ring: list[dict] = [] + for parent_id, children in by_parent.items(): + parent_node = nodes.get(parent_id) + if parent_node is None: + continue + parent_score = parent_node.cumulative_score + if parent_score == 0.0: + continue + + children_score_sum = sum(c.cumulative_score for c in children) + for child in children: + share = child.cumulative_score / parent_score if parent_score else 0.0 + ring.append({ + "unique_id": child.unique_id, + "label": getattr(child, "_label", str(child.unique_id)), + "share": share, + "cumulative_score": child.cumulative_score, + "parent_unique_id": parent_id, + "is_other": False, + }) + + # "other" wedge for the remainder + remainder = parent_score - children_score_sum + if abs(remainder) > abs(parent_score) * 1e-9: + ring.append({ + "unique_id": None, + "label": "other", + "share": remainder / parent_score, + "cumulative_score": remainder, + "parent_unique_id": parent_id, + "is_other": True, + }) + + if ring: + rings.append(ring) + + return rings + + +def _find_parent(child_uid: NodeId, edges: list) -> NodeId | None: + """Return the consumer_unique_id of the edge whose producer is child_uid.""" + for edge in edges: + if edge.producer_unique_id == child_uid: + return edge.consumer_unique_id + return None + + +# --------------------------------------------------------------------------- +# Export flattening +# --------------------------------------------------------------------------- + +def flatten_to_dataframe( + nodes: dict, + edges: list, + total_score: float, + metadata_lookup: Callable[[int], dict] | None = None, +) -> pd.DataFrame: + """Depth-first walk of the traversal state; return one row per node. + + Parameters + ---------- + nodes: + ``state.nodes`` dict. + edges: + ``state.edges`` list. + total_score: + ``lca.score``. + metadata_lookup: + Optional callable that accepts an ``activity_datapackage_id`` and + returns a dict with keys ``product``, ``name``, ``location``, + ``database``, ``unit``. If None, these columns will be empty strings. + """ + parent_child = build_parent_child_map(nodes, edges) + + # Find root (depth == 0 or negative unique_id) + root_candidates = [n for n in nodes.values() if n.depth == 0] + if not root_candidates: + return pd.DataFrame() + root = root_candidates[0] + tiers = compute_node_tiers(nodes, edges, root.unique_id) + + rows: list[dict] = [] + # Skip virtual demand root — export matches the visible tree (RF = tier 0) + for child_uid in parent_child.get(root.unique_id, []): + _dfs(child_uid, nodes, parent_child, total_score, metadata_lookup, rows, tiers) + + return pd.DataFrame(rows, columns=[ + "Cumulative impact (%)", + "Direct impact (%)", + "Product", + "Process", + "Location", + "Database", + "Flow amount", + "Unit", + "Cumulative impact", + "Direct impact", + "Tier", + ]) + + +def _dfs( + uid: NodeId, + nodes: dict, + parent_child: dict, + total_score: float, + metadata_lookup: Callable | None, + rows: list, + tiers: dict[NodeId, int], +) -> None: + node = nodes.get(uid) + if node is None: + return + + meta = {} + if metadata_lookup is not None: + meta = metadata_lookup(getattr(node, "activity_datapackage_id", None)) or {} + + rows.append({ + "Cumulative impact (%)": cumulative_percent(node, total_score), + "Direct impact (%)": direct_percent(node, total_score), + "Product": meta.get("product", ""), + "Process": meta.get("name", ""), + "Location": meta.get("location", ""), + "Database": meta.get("database", ""), + "Flow amount": getattr(node, "supply_amount", 0.0), + "Unit": meta.get("unit", ""), + "Cumulative impact": getattr(node, "cumulative_score", 0.0), + "Direct impact": getattr(node, "direct_emissions_score", 0.0), + "Tier": tiers.get(uid, getattr(node, "depth", 0)), + }) + + for child_uid in parent_child.get(uid, []): + _dfs(child_uid, nodes, parent_child, total_score, metadata_lookup, rows, tiers) diff --git a/activity_browser/ui/delegates/__init__.py b/activity_browser/ui/delegates/__init__.py index c80635da9..c9b52d99d 100644 --- a/activity_browser/ui/delegates/__init__.py +++ b/activity_browser/ui/delegates/__init__.py @@ -13,9 +13,12 @@ from .date_time import DateTimeDelegate from .property import PropertyDelegate from .amount import AmountDelegate, AbsoluteAmountDelegate +from .impact_background import ImpactBackgroundDelegate, impact_intensity_fraction from .card import CardDelegate __all__ = [ + "ImpactBackgroundDelegate", + "impact_intensity_fraction", "AmountDelegate", "AbsoluteAmountDelegate", "CheckboxDelegate", diff --git a/activity_browser/ui/delegates/impact_background.py b/activity_browser/ui/delegates/impact_background.py new file mode 100644 index 000000000..acac709fb --- /dev/null +++ b/activity_browser/ui/delegates/impact_background.py @@ -0,0 +1,114 @@ +"""Delegate that tints table cells by signed impact magnitude. + +Used by the Contribution Tree tab for percentage columns. Intensity uses a +log scale focused on the 1%–100% range so 1 / 10 / 50 / 100 read clearly +apart, still as a plain translucent color fill (no custom bar widgets). +""" + +from __future__ import annotations + +import math + +from qtpy import QtCore, QtGui, QtWidgets + + +def impact_intensity_fraction( + value: float, + column_max: float, + *, + floor_ratio: float = 0.01, +) -> float: + """Map ``|value|`` to ``[0, 1]`` on a log10 axis from ``floor`` to ``column_max``. + + Default ``floor_ratio=0.01`` puts the floor at 1% when ``column_max`` is 100, + so 1 → 0, 10 → ~0.5, 50 → ~0.85, 100 → 1. Values below the floor share the + minimum intensity. + """ + if column_max <= 0 or value == 0: + return 0.0 + lo = max(abs(column_max) * floor_ratio, 1e-12) + hi = abs(column_max) + if lo >= hi: + return 1.0 if abs(value) >= hi else 0.0 + v = min(max(abs(value), lo), hi) + return (math.log10(v) - math.log10(lo)) / (math.log10(hi) - math.log10(lo)) + + +class ImpactBackgroundDelegate(QtWidgets.QStyledItemDelegate): + """Paint a translucent full-cell background from a signed numeric value. + + Tint intensity uses :func:`impact_intensity_fraction` (log-scaled). + Positive and negative hues are configurable (e.g. red for cumulative %, + blue for direct %). + + Parameters + ---------- + column_max: + Maximum absolute value in the column — used to scale tint intensity. + positive_rgb: + RGB triple for positive (burden) values. + negative_rgb: + RGB triple for negative (credit) values. + parent: + Optional Qt parent. + """ + + VALUE_ROLE = QtCore.Qt.UserRole + 10 + + def __init__( + self, + column_max: float = 100.0, + positive_rgb: tuple[int, int, int] = (210, 85, 85), + negative_rgb: tuple[int, int, int] = (85, 170, 95), + parent=None, + ): + super().__init__(parent) + self.column_max = column_max + self.positive_rgb = positive_rgb + self.negative_rgb = negative_rgb + + def paint( + self, + painter: QtGui.QPainter, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> None: + self.initStyleOption(option, index) + painter.save() + + style = option.widget.style() if option.widget else QtWidgets.QApplication.style() + style.drawPrimitive(QtWidgets.QStyle.PE_PanelItemViewItem, option, painter, option.widget) + + value = index.data(self.VALUE_ROLE) + tint = self._impact_tint(value) + if tint is not None: + painter.fillRect(option.rect, tint) + + text = index.data(QtCore.Qt.DisplayRole) + if text is not None: + text_rect = option.rect.adjusted(4, 0, -4, 0) + palette = option.palette + if option.state & QtWidgets.QStyle.State_Selected: + colour = palette.highlightedText().color() + else: + colour = palette.text().color() + painter.setPen(colour) + painter.drawText( + text_rect, + QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft, + str(text), + ) + + painter.restore() + + def _impact_tint(self, value) -> QtGui.QColor | None: + if value is None or not isinstance(value, (int, float)) or value == 0: + return None + if self.column_max <= 0: + return None + + fraction = impact_intensity_fraction(value, self.column_max) + # Strong span: near-transparent at floor → nearly solid at max + alpha = int(25 + 200 * fraction) + r, g, b = self.positive_rgb if value > 0 else self.negative_rgb + return QtGui.QColor(r, g, b, min(alpha, 230)) diff --git a/tests/test_contribution_tree.py b/tests/test_contribution_tree.py new file mode 100644 index 000000000..6dbdd178a --- /dev/null +++ b/tests/test_contribution_tree.py @@ -0,0 +1,645 @@ +"""Tests for bwutils.contribution_tree data helpers. + +All tests use plain SimpleNamespace fake objects — no Qt, no Brightway project. +Pattern follows tests/test_contribution_normalize.py and test_lcia_overview.py. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pandas as pd +import pytest + +from activity_browser.bwutils.contribution_tree import ( + build_parent_child_map, + build_sunburst_rings, + coverage_of_uids, + cumulative_percent, + direct_impact_coverage, + direct_percent, + flatten_to_dataframe, + next_expand_candidates, + path_display_set, + plan_cumulative_expand, + tree_stats, +) + + +# --------------------------------------------------------------------------- +# Helpers: fake traversal objects +# --------------------------------------------------------------------------- + +def _node(uid, depth, cumulative, direct, supply=1.0, activity_id=None): + return SimpleNamespace( + unique_id=uid, + depth=depth, + cumulative_score=cumulative, + direct_emissions_score=direct, + supply_amount=supply, + activity_datapackage_id=activity_id or uid, + ) + + +def _edge(consumer_uid, producer_uid): + return SimpleNamespace( + consumer_unique_id=consumer_uid, + producer_unique_id=producer_uid, + ) + + +# --------------------------------------------------------------------------- +# build_parent_child_map +# --------------------------------------------------------------------------- + +def test_parent_child_map_basic(): + nodes = { + -1: _node(-1, 0, 10.0, 0.0), + 1: _node(1, 1, 6.0, 2.0), + 2: _node(2, 1, 4.0, 1.0), + } + edges = [_edge(-1, 1), _edge(-1, 2)] + pcm = build_parent_child_map(nodes, edges) + assert sorted(pcm[-1]) == [1, 2] + assert pcm[1] == [] + assert pcm[2] == [] + + +def test_parent_child_map_root_no_children(): + nodes = {-1: _node(-1, 0, 5.0, 0.0)} + edges = [] + pcm = build_parent_child_map(nodes, edges) + assert pcm[-1] == [] + + +def test_parent_child_map_deep(): + nodes = { + -1: _node(-1, 0, 10.0, 0.0), + 1: _node(1, 1, 6.0, 1.0), + 2: _node(2, 2, 3.0, 1.0), + } + edges = [_edge(-1, 1), _edge(1, 2)] + pcm = build_parent_child_map(nodes, edges) + assert pcm[-1] == [1] + assert pcm[1] == [2] + assert pcm[2] == [] + + +def test_parent_child_map_no_duplicate_children(): + """Duplicate edges should not produce duplicate child entries.""" + nodes = {-1: _node(-1, 0, 10.0, 0.0), 1: _node(1, 1, 6.0, 1.0)} + edges = [_edge(-1, 1), _edge(-1, 1)] # duplicate + pcm = build_parent_child_map(nodes, edges) + assert pcm[-1].count(1) == 1 + + +# --------------------------------------------------------------------------- +# cumulative_percent / direct_percent +# --------------------------------------------------------------------------- + +def test_cumulative_percent_normal(): + node = _node(1, 1, 6.0, 2.0) + assert cumulative_percent(node, 10.0) == pytest.approx(60.0) + + +def test_cumulative_percent_zero_total(): + node = _node(1, 1, 6.0, 2.0) + assert cumulative_percent(node, 0.0) == 0.0 + + +def test_cumulative_percent_full(): + node = _node(1, 1, 10.0, 5.0) + assert cumulative_percent(node, 10.0) == pytest.approx(100.0) + + +def test_direct_percent_normal(): + node = _node(1, 1, 6.0, 2.0) + assert direct_percent(node, 10.0) == pytest.approx(20.0) + + +def test_direct_percent_zero_total(): + node = _node(1, 1, 6.0, 2.0) + assert direct_percent(node, 0.0) == 0.0 + + +# --------------------------------------------------------------------------- +# build_sunburst_rings +# --------------------------------------------------------------------------- + +def _simple_tree(): + """Two-tier tree: root → A(6), B(4); A → C(3), D(2).""" + nodes = { + -1: _node(-1, 0, 10.0, 0.0), + 1: _node(1, 1, 6.0, 1.0), + 2: _node(2, 1, 4.0, 1.0), + 3: _node(3, 2, 3.0, 1.0), + 4: _node(4, 2, 2.0, 1.0), + } + edges = [ + _edge(-1, 1), _edge(-1, 2), + _edge(1, 3), _edge(1, 4), + ] + return nodes, edges + + +def test_sunburst_rings_tier1_shares(): + nodes, edges = _simple_tree() + rings = build_sunburst_rings(nodes, edges, 10.0, max_depth=1) + assert len(rings) == 1 + ring = rings[0] + real_wedges = [w for w in ring if not w["is_other"]] + shares = {w["unique_id"]: w["share"] for w in real_wedges} + assert shares[1] == pytest.approx(0.6) + assert shares[2] == pytest.approx(0.4) + + +def test_sunburst_rings_sum_le_one_per_parent(): + nodes, edges = _simple_tree() + rings = build_sunburst_rings(nodes, edges, 10.0, max_depth=2) + # Check tier-2 ring: children of node 1 (C=3, D=2) should sum to 5/6 + ring2 = rings[1] + parent1_wedges = [w for w in ring2 if w["parent_unique_id"] == 1] + total_share = sum(w["share"] for w in parent1_wedges) + assert total_share <= 1.0 + 1e-9 + + +def test_sunburst_rings_other_wedge_present_when_children_dont_sum(): + """Children C(3) + D(2) = 5; parent A(6) → other = 1/6.""" + nodes, edges = _simple_tree() + rings = build_sunburst_rings(nodes, edges, 10.0, max_depth=2) + ring2 = rings[1] + others = [w for w in ring2 if w["is_other"] and w["parent_unique_id"] == 1] + assert len(others) == 1 + assert others[0]["share"] == pytest.approx(1 / 6, rel=1e-6) + + +def test_sunburst_rings_max_depth_respected(): + nodes, edges = _simple_tree() + rings = build_sunburst_rings(nodes, edges, 10.0, max_depth=1) + assert len(rings) == 1 # only tier-1 ring + + +def test_sunburst_rings_empty_on_zero_total(): + nodes, edges = _simple_tree() + rings = build_sunburst_rings(nodes, edges, 0.0, max_depth=2) + assert rings == [] + + +def test_sunburst_rings_no_other_when_children_match_parent(): + """When children sum exactly to parent, no 'other' wedge for that parent.""" + nodes = { + -1: _node(-1, 0, 10.0, 0.0), + 1: _node(1, 1, 6.0, 1.0), + 2: _node(2, 1, 4.0, 1.0), + } + edges = [_edge(-1, 1), _edge(-1, 2)] + rings = build_sunburst_rings(nodes, edges, 10.0, max_depth=1) + others = [w for w in rings[0] if w["is_other"]] + assert others == [] + + +# --------------------------------------------------------------------------- +# flatten_to_dataframe +# --------------------------------------------------------------------------- + +def _meta(uid): + return { + "product": f"product_{uid}", + "name": f"process_{uid}", + "location": "GLO", + "database": "testdb", + "unit": "kg", + } + + +def test_flatten_to_dataframe_row_count(): + nodes, edges = _simple_tree() + df = flatten_to_dataframe(nodes, edges, 10.0, metadata_lookup=_meta) + # Virtual root skipped; 4 visible nodes + assert len(df) == 4 + + +def test_flatten_to_dataframe_no_duplicates(): + nodes, edges = _simple_tree() + df = flatten_to_dataframe(nodes, edges, 10.0, metadata_lookup=_meta) + assert df.duplicated().sum() == 0 + + +def test_flatten_to_dataframe_tier_column(): + nodes, edges = _simple_tree() + df = flatten_to_dataframe(nodes, edges, 10.0, metadata_lookup=_meta) + # RF = 0, their suppliers = 1 + assert set(df["Tier"].tolist()) == {0, 1} + + +def test_flatten_to_dataframe_root_cumulative_percent(): + nodes, edges = _simple_tree() + df = flatten_to_dataframe(nodes, edges, 10.0, metadata_lookup=_meta) + t0 = df[df["Tier"] == 0] + assert t0["Cumulative impact (%)"].sum() == pytest.approx(100.0) + + +def test_flatten_to_dataframe_columns(): + nodes, edges = _simple_tree() + df = flatten_to_dataframe(nodes, edges, 10.0) + expected = [ + "Cumulative impact (%)", "Direct impact (%)", "Product", "Process", + "Location", "Database", "Flow amount", "Unit", "Cumulative impact", + "Direct impact", "Tier", + ] + assert list(df.columns) == expected + + +def test_flatten_to_dataframe_depth_first_order(): + """Root → node1 → node3 → node4 → node2 in DFS.""" + nodes, edges = _simple_tree() + df = flatten_to_dataframe(nodes, edges, 10.0, metadata_lookup=_meta) + # Node 3 and 4 (children of 1) should appear before node 2 (sibling of 1) + idx_1 = df.index[df["Cumulative impact"] == 6.0].tolist()[0] + idx_2 = df.index[df["Cumulative impact"] == 4.0].tolist()[0] + idx_3 = df.index[df["Cumulative impact"] == 3.0].tolist()[0] + assert idx_1 < idx_3 < idx_2 + + +def test_flatten_to_dataframe_no_metadata_lookup(): + nodes, edges = _simple_tree() + df = flatten_to_dataframe(nodes, edges, 10.0, metadata_lookup=None) + assert (df["Product"] == "").all() + assert (df["Process"] == "").all() + + +# --------------------------------------------------------------------------- +# direct_impact_coverage / tree_stats / next_expand_candidates +# --------------------------------------------------------------------------- + +def _visible_nodes(): + """Depth-0 virtual root + three tier-1 suppliers (no further children yet).""" + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 1, 50.0, 20.0), + 2: _node(2, 1, 30.0, 10.0), + 3: _node(3, 1, 20.0, 5.0), + } + edges = [_edge(-1, 1), _edge(-1, 2), _edge(-1, 3)] + return nodes, edges + + +def test_direct_impact_coverage_partial(): + nodes, _ = _visible_nodes() + # With root_uid: skip virtual root; 20+10+5 = 35 / 100 + assert direct_impact_coverage(nodes, 100.0, root_uid=-1) == pytest.approx(0.35) + # Without root_uid: depth must not be used as a filter (root direct is 0) + assert direct_impact_coverage(nodes, 100.0) == pytest.approx(0.35) + + +def test_direct_impact_coverage_ignores_mutated_depth(): + """After traverse_from_node we zero depth; coverage must still count nodes.""" + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 0, 50.0, 20.0), + 2: _node(2, 0, 30.0, 15.0), + } + assert direct_impact_coverage(nodes, 100.0, root_uid=-1) == pytest.approx(0.35) + + +def test_direct_impact_coverage_zero_total(): + nodes, _ = _visible_nodes() + assert direct_impact_coverage(nodes, 0.0) == 0.0 + + +def test_direct_impact_coverage_empty(): + assert direct_impact_coverage({}, 100.0) == 0.0 + + +def test_tree_stats(): + nodes, edges = _visible_nodes() + stats = tree_stats(nodes, 100.0, root_uid=-1, edges=edges) + assert stats["node_count"] == 3 + assert stats["coverage"] == pytest.approx(0.35) + assert stats["max_tier"] == 0 # RF rows at display tier 0 + + +def test_tree_stats_max_tier_ignores_mutated_depth(): + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 0, 50.0, 10.0), # mutated + 11: _node(11, 0, 20.0, 5.0), # mutated + } + edges = [_edge(-1, 1), _edge(1, 11)] + stats = tree_stats(nodes, 100.0, root_uid=-1, edges=edges) + assert stats["max_tier"] == 1 + + +def test_tier_candidates_expand_depth_below_n(): + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 1, 60.0, 10.0), + 2: _node(2, 1, 40.0, 10.0), + 11: _node(11, 2, 30.0, 5.0), + } + edges = [_edge(-1, 1), _edge(-1, 2), _edge(1, 11)] + visited = {-1, 1} # 2 and 11 not visited + cands = next_expand_candidates( + nodes, edges, visited, mode="tier", value=3, total_score=100.0, root_uid=-1 + ) + # Display tiers: 1→0, 2→0, 11→1; unvisited with tier < 3 + assert set(cands) == {2, 11} + + +def test_compute_node_tiers_ignores_mutated_depth(): + from activity_browser.bwutils.contribution_tree import compute_node_tiers + + # After traverse_from_node, Brightway may reset a mid-tree node's depth to 0 + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 0, 50.0, 10.0), # mutated depth! + 11: _node(11, 1, 20.0, 5.0), # mutated child depth + } + edges = [_edge(-1, 1), _edge(1, 11)] + tiers = compute_node_tiers(nodes, edges, root_uid=-1) + assert -1 not in tiers + assert tiers[1] == 0 + assert tiers[11] == 1 + + +def test_path_candidates_above_threshold_only(): + nodes, edges = _visible_nodes() + visited = {-1} # none of the RF nodes visited + cands = next_expand_candidates( + nodes, edges, visited, mode="path", value=35.0, total_score=100.0 + ) + # Only node 1 has cumulative 50 >= 35% of total + assert cands == [1] + + +def test_path_skips_already_visited(): + nodes, edges = _visible_nodes() + visited = {-1, 1} + cands = next_expand_candidates( + nodes, edges, visited, mode="path", value=1.0, total_score=100.0 + ) + assert set(cands) == {2, 3} + + +def test_path_does_not_auto_expand_children_below_threshold(): + """Sub-threshold path-impact children stay out of the candidate set.""" + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 1, 50.0, 10.0), + 11: _node(11, 2, 40.0, 5.0), # 40% path — above 10% + 12: _node(12, 2, 5.0, 1.0), # 5% path — below 10% + } + edges = [_edge(-1, 1), _edge(1, 11), _edge(1, 12)] + visited = {-1, 1} # parent already expanded; children present but unvisited + cands = next_expand_candidates( + nodes, edges, visited, mode="path", value=10.0, total_score=100.0 + ) + assert cands == [11] + assert 12 not in cands + + +def test_cumulative_skips_excluded_and_prefers_eligible(): + """Largest global unvisited is skipped if excluded / not eligible (not in view).""" + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 1, 80.0, 1.0), # largest, but not eligible + 2: _node(2, 1, 50.0, 1.0), + } + edges = [_edge(-1, 1), _edge(-1, 2)] + cands = next_expand_candidates( + nodes, + edges, + {-1}, + mode="cumulative", + value=90.0, + total_score=100.0, + eligible_ids={2}, + ) + assert cands == [2] + cands2 = next_expand_candidates( + nodes, + edges, + {-1}, + mode="cumulative", + value=90.0, + total_score=100.0, + exclude={1}, + ) + assert cands2 == [2] + + +def test_cumulative_tie_break_prefers_lower_unique_id(): + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 5: _node(5, 1, 40.0, 1.0), + 2: _node(2, 1, 40.0, 1.0), + } + edges = [_edge(-1, 5), _edge(-1, 2)] + cands = next_expand_candidates( + nodes, edges, {-1}, mode="cumulative", value=50.0, total_score=100.0 + ) + assert cands == [2] + + +def test_cumulative_returns_largest_unvisited_until_coverage(): + nodes, edges = _visible_nodes() + visited = {-1} + # coverage of visible directs = 0.35; target 50% → need to expand + cands = next_expand_candidates( + nodes, edges, visited, mode="cumulative", value=50.0, total_score=100.0 + ) + # One step: largest unvisited by abs cumulative = node 1 (50) + assert cands == [1] + + +def test_cumulative_stops_when_coverage_met(): + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 1, 50.0, 80.0), + 2: _node(2, 1, 30.0, 10.0), + } + edges = [_edge(-1, 1), _edge(-1, 2)] + visited = {-1} + cands = next_expand_candidates( + nodes, edges, visited, mode="cumulative", value=80.0, total_score=100.0 + ) + # visible directs already 90 >= 80% + assert cands == [] + + +def test_cumulative_empty_when_nothing_left(): + nodes, edges = _visible_nodes() + visited = set(nodes) + cands = next_expand_candidates( + nodes, edges, visited, mode="cumulative", value=99.0, total_score=100.0 + ) + assert cands == [] + + +def test_plan_cumulative_stops_near_target_not_all_nodes(): + """Deep calculated graph: display set should stay near the target, not dump all.""" + # RF --20--> A --40--> B --30--> C (directs). Extra deep branch D,E unused for 50%. + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 1, 90.0, 5.0), # RF + 2: _node(2, 2, 80.0, 10.0), # A + 3: _node(3, 3, 60.0, 40.0), # B — opening A then B should cross 50% + 4: _node(4, 4, 20.0, 20.0), # C + 5: _node(5, 2, 5.0, 5.0), # sibling noise under RF + 6: _node(6, 3, 4.0, 4.0), + } + edges = [ + _edge(-1, 1), + _edge(1, 2), + _edge(1, 5), + _edge(2, 3), + _edge(3, 4), + _edge(5, 6), + ] + visited = set(nodes) # everything already calculated + included, to_expand, need = plan_cumulative_expand( + nodes, edges, -1, 100.0, 50.0, visited + ) + assert need is None + # Must not pull in the whole graph (4, 6 especially) + assert 4 not in included + assert 6 not in included + assert coverage_of_uids(nodes, included, 100.0) >= 0.50 + assert coverage_of_uids(nodes, included, 100.0) < 0.80 + assert 1 in to_expand # opened RF path + + +def test_plan_cumulative_adds_only_needed_siblings(): + """Last open must not dump every sibling once the target is already met.""" + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 1, 100.0, 10.0), # RF + # Children of RF: largest cumulative first; directs 25 each after RF's 10 + 2: _node(2, 2, 50.0, 25.0), + 3: _node(3, 2, 40.0, 25.0), + 4: _node(4, 2, 30.0, 25.0), + 5: _node(5, 2, 20.0, 25.0), + } + edges = [ + _edge(-1, 1), + _edge(1, 2), + _edge(1, 3), + _edge(1, 4), + _edge(1, 5), + ] + visited = set(nodes) + # Target 60%: RF(10) + child2(25) + child3(25) = 60 — must NOT add 4 and 5 + included, to_expand, need = plan_cumulative_expand( + nodes, edges, -1, 100.0, 60.0, visited + ) + assert need is None + assert to_expand == {1} + assert included == {1, 2, 3} + assert coverage_of_uids(nodes, included, 100.0) == pytest.approx(0.60) + + +def test_plan_cumulative_skips_mostly_direct_terminal(): + """Peat-moss-like node: high direct, tiny upstream — listed but not opened.""" + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 1, 100.0, 20.0), # RF + 2: _node(2, 2, 50.0, 5.0), # continue path (lots of upstream) + 3: _node(3, 3, 40.0, 39.0), # ~97.5% direct — must NOT open + 4: _node(4, 4, 0.5, 0.3), + 5: _node(5, 4, 0.3, 0.2), + } + edges = [ + _edge(-1, 1), + _edge(1, 2), + _edge(2, 3), + _edge(3, 4), + _edge(3, 5), + ] + visited = set(nodes) + # 20 + 5 + 39 = 64% once node 3 is listed under opened node 2 + included, to_expand, need = plan_cumulative_expand( + nodes, edges, -1, 100.0, 60.0, visited + ) + assert need is None + assert coverage_of_uids(nodes, included, 100.0) >= 0.60 + assert 3 in included + assert 3 not in to_expand + assert 4 not in included and 5 not in included + assert 1 in to_expand and 2 in to_expand + + +def test_plan_cumulative_requests_traverse_for_unvisited(): + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 1, 90.0, 5.0), + } + edges = [_edge(-1, 1)] + included, to_expand, need = plan_cumulative_expand( + nodes, edges, -1, 100.0, 60.0, visited={-1} + ) + assert need == 1 + assert included == {1} + assert to_expand == set() + + +def test_coverage_of_uids(): + nodes = { + 1: _node(1, 1, 50.0, 20.0), + 2: _node(2, 1, 30.0, 10.0), + } + assert coverage_of_uids(nodes, {1, 2}, 100.0) == pytest.approx(0.30) + assert coverage_of_uids(nodes, {1}, 100.0) == pytest.approx(0.20) + + +def test_path_display_set_keeps_below_threshold_siblings(): + """Path expand opens nodes that continue a >=X% path; lists their siblings.""" + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 1, 100.0, 5.0), # RF — above 10%, has child 2 >= 10% → expand + 2: _node(2, 2, 40.0, 10.0), # above 10%, has child 4 >= 10% → expand + 3: _node(3, 2, 5.0, 1.0), # below 10% sibling — listed under expanded RF + 4: _node(4, 3, 30.0, 8.0), # above 10%, no child >= 10% → listed, NOT expanded + 5: _node(5, 3, 2.0, 0.5), # below 10% sibling under 2 — listed + 6: _node(6, 3, 1.0, 0.2), # child of 3 — not listed (3 not expanded) + 7: _node(7, 4, 1.0, 0.1), # child of terminal 4 — must NOT be listed + } + edges = [ + _edge(-1, 1), + _edge(1, 2), + _edge(1, 3), + _edge(2, 4), + _edge(2, 5), + _edge(3, 6), + _edge(4, 7), + ] + visited = {-1, 1, 2, 4} + included, to_expand = path_display_set( + nodes, edges, -1, 100.0, 10.0, visited + ) + assert 3 in included # sibling under RF + assert 5 in included # sibling under node 2 + assert 4 in included # terminal high-path node stays visible + assert 4 not in to_expand # but is not auto-opened + assert 7 not in included # children of terminal high-path stay hidden + assert 6 not in included + assert to_expand == {1, 2} + + +def test_path_display_set_terminal_high_path_not_opened(): + """Like peat moss at 12% with only <2% children — show the row, don't open it.""" + nodes = { + -1: _node(-1, 0, 100.0, 0.0), + 1: _node(1, 1, 100.0, 30.0), + 2: _node(2, 2, 12.5, 12.0), + 3: _node(3, 3, 0.26, 0.1), + 4: _node(4, 3, 0.10, 0.05), + } + edges = [_edge(-1, 1), _edge(1, 2), _edge(2, 3), _edge(2, 4)] + visited = {-1, 1, 2} + included, to_expand = path_display_set( + nodes, edges, -1, 100.0, 2.0, visited + ) + assert 2 in included + assert 2 not in to_expand + assert 3 not in included and 4 not in included + assert to_expand == {1} diff --git a/tests/test_impact_background.py b/tests/test_impact_background.py new file mode 100644 index 000000000..762c61697 --- /dev/null +++ b/tests/test_impact_background.py @@ -0,0 +1,31 @@ +"""Tests for log-scaled impact tint intensity (no widget needed).""" + +from __future__ import annotations + +import pytest + +from activity_browser.ui.delegates.impact_background import impact_intensity_fraction + + +def test_log_scale_separates_one_ten_fifty_hundred(): + """1 / 10 / 50 / 100 must spread clearly under max=100 (floor at 1%).""" + one = impact_intensity_fraction(1.0, 100.0) + ten = impact_intensity_fraction(10.0, 100.0) + fifty = impact_intensity_fraction(50.0, 100.0) + hundred = impact_intensity_fraction(100.0, 100.0) + assert one == pytest.approx(0.0) + assert hundred == pytest.approx(1.0) + assert ten == pytest.approx(0.5) + assert 0.8 < fifty < 0.95 + assert ten - one > 0.4 + assert hundred - fifty > 0.05 + assert fifty - ten > 0.2 + + +def test_log_scale_zero_and_invalid_max(): + assert impact_intensity_fraction(0.0, 100.0) == 0.0 + assert impact_intensity_fraction(10.0, 0.0) == 0.0 + + +def test_log_scale_clamps_above_max(): + assert impact_intensity_fraction(200.0, 100.0) == pytest.approx(1.0) From 455f6fd0667047beb3fc40a1813d3065835de153 Mon Sep 17 00:00:00 2001 From: bsteubing
+Shown = visible rows, their direct-impact share, and deepest visible +tier (updates on expand/collapse). Calculated = all nodes discovered +by graph traversal, their direct-impact coverage, and deepest calculated +tier.Date: Tue, 11 Aug 2026 23:39:03 +0200 Subject: [PATCH 07/10] Second, refined working version of Contribution Tree tab --- .../lca_results/contribution_tree_model.py | 415 ++++++++++++ .../lca_results/contribution_tree_plot.py | 147 ++++ .../lca_results/contribution_tree_tab.py | 632 +----------------- activity_browser/bwutils/contribution_tree.py | 66 +- tests/test_contribution_tree.py | 81 +-- 5 files changed, 632 insertions(+), 709 deletions(-) create mode 100644 activity_browser/app/pages/lca_results/contribution_tree_model.py create mode 100644 activity_browser/app/pages/lca_results/contribution_tree_plot.py diff --git a/activity_browser/app/pages/lca_results/contribution_tree_model.py b/activity_browser/app/pages/lca_results/contribution_tree_model.py new file mode 100644 index 000000000..baa059c8e --- /dev/null +++ b/activity_browser/app/pages/lca_results/contribution_tree_model.py @@ -0,0 +1,415 @@ +"""Qt item model for the Contribution Tree tab.""" + +from __future__ import annotations + +from typing import Optional + +import bw2data as bd +from qtpy import QtCore, QtGui + +from bw_graph_tools.graph_traversal import SameNodeEachVisitGraphTraversal + +from activity_browser.bwutils.contribution_tree import ( + build_parent_child_map, + compute_node_tiers, + cumulative_percent, + direct_percent, + flatten_to_dataframe, + suppress_graph_traversal_warnings, +) +from activity_browser.ui.delegates.impact_background import ImpactBackgroundDelegate + +COL_CUMULATIVE_PCT = 0 +COL_DIRECT_PCT = 1 +COL_PRODUCT = 2 +COL_PROCESS = 3 +COL_LOCATION = 4 +COL_DATABASE = 5 +COL_FLOW_AMOUNT = 6 +COL_UNIT = 7 +COL_CUMULATIVE = 8 +COL_DIRECT = 9 +COL_TIER = 10 + +COLUMNS = [ + "Cumulative impact (%)", + "Direct impact (%)", + "Product", + "Process", + "Location", + "Database", + "Flow amount", + "Unit", + "Cumulative impact", + "Direct impact", + "Tier", +] + +# Columns that get the impact-background delegate (signed magnitude values) +BAR_COLUMNS = (COL_CUMULATIVE_PCT, COL_DIRECT_PCT, COL_CUMULATIVE, COL_DIRECT) + +EXPAND_MODE_TIER = "tier" +EXPAND_MODE_PATH = "path" +EXPAND_MODE_CUMULATIVE = "cumulative" + +# Role for contribution-tree node unique_id on the first-column item +UID_ROLE = QtCore.Qt.UserRole + 1 +PLACEHOLDER_ROLE = QtCore.Qt.UserRole + 2 +TIER_ROLE = QtCore.Qt.UserRole + 3 + + + +class ContributionTreeModel(QtGui.QStandardItemModel): + """QStandardItemModel backed by a SameNodeEachVisitGraphTraversal state. + + Populated lazily: call ``load_state`` after initial traversal, then + ``expand_node`` from a queued ``expanded`` handler. Empty placeholder + children provide expand chevrons without visible ellipsis text. + """ + + column_max_changed = QtCore.Signal() + + def __init__(self, parent=None): + super().__init__(0, len(COLUMNS), parent) + self.setHorizontalHeaderLabels(COLUMNS) + self._state: Optional[SameNodeEachVisitGraphTraversal] = None + self._total_score: float = 0.0 + self._root_uid: int | None = None + self._tiers: dict[int, int] = {} + # Maps unique_id → QStandardItem (the first-column item for that row) + self._uid_to_item: dict[int, QtGui.QStandardItem] = {} + # Column max values for the bar-background delegates + self.col_max: dict[int, float] = {c: 1.0 for c in BAR_COLUMNS} + self._expanding: bool = False + self._meta_cache: dict = {} + self._batch_updating: bool = False + + @staticmethod + def has_real_children(item: QtGui.QStandardItem) -> bool: + for row in range(item.rowCount()): + child = item.child(row, 0) + if child is not None and not child.data(PLACEHOLDER_ROLE): + return True + return False + + def _strip_placeholders(self, parent_item: QtGui.QStandardItem) -> None: + for row in range(parent_item.rowCount() - 1, -1, -1): + child = parent_item.child(row, 0) + if child is not None and child.data(PLACEHOLDER_ROLE): + parent_item.removeRow(row) + + def _ensure_placeholder(self, first: QtGui.QStandardItem) -> None: + """Empty child so the view shows a chevron (no visible ellipsis text).""" + if first.rowCount() > 0: + return + ph = QtGui.QStandardItem("") + ph.setEditable(False) + ph.setData(True, PLACEHOLDER_ROLE) + ph.setFlags(QtCore.Qt.ItemFlag.NoItemFlags) + first.appendRow( + [ph] + [QtGui.QStandardItem("") for _ in range(len(COLUMNS) - 1)] + ) + + def _refresh_tiers(self) -> None: + if self._state is None or self._root_uid is None: + self._tiers = {} + return + self._tiers = compute_node_tiers( + self._state.nodes, self._state.edges, self._root_uid + ) + + + # ------------------------------------------------------------------ + # Public accessors (for the tab; avoid reading private maps) + # ------------------------------------------------------------------ + + @property + def root_uid(self) -> int | None: + return self._root_uid + + def item_for_uid(self, unique_id: int) -> QtGui.QStandardItem | None: + return self._uid_to_item.get(unique_id) + + def iter_uid_items(self): + """Yield ``(unique_id, first_column_item)`` for rows currently in the model.""" + return self._uid_to_item.items() + + def model_uids(self) -> set[int]: + return set(self._uid_to_item.keys()) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def load_state( + self, + state: SameNodeEachVisitGraphTraversal, + total_score: float, + ) -> None: + """Rebuild the model from a (possibly cached) traversal state.""" + self.clear() + self.setHorizontalHeaderLabels(COLUMNS) + self._state = state + self._total_score = total_score + self._uid_to_item = {} + self.col_max = {c: 1.0 for c in BAR_COLUMNS} + self._meta_cache = {} + self._root_uid = state._root_node.unique_id + self._refresh_tiers() + + pcm = build_parent_child_map(state.nodes, state.edges) + root_children = [ + state.nodes[uid] + for uid in pcm.get(self._root_uid, []) + if uid in state.nodes + ] + root_children.sort(key=lambda n: abs(n.cumulative_score), reverse=True) + self._batch_updating = True + try: + for child in root_children: + self._add_node(child, self.invisibleRootItem(), pcm) + finally: + self._batch_updating = False + + def expand_node( + self, + unique_id: int, + min_path_pct: float | None = None, + ) -> bool: + """Traverse from the given node and add its direct children to the model. + + All children discovered by graph traversal are listed (the engine cutoff + already limits which edges exist). ``min_path_pct`` is ignored for + listing — it only affects auto-expand policy elsewhere. + """ + if self._state is None or self._expanding: + return False + + parent_item = self._uid_to_item.get(unique_id) + if parent_item is None: + return False + + self._expanding = True + try: + self._strip_placeholders(parent_item) + + if unique_id not in self._state.visited_nodes: + node = self._state.nodes.get(unique_id) + if node is None: + parent_item.emitDataChanged() + return False + # Brightway computes max_depth from node.depth *before* resetting + # depth to 0. Without zeroing here, traverse_from_node(depth=1) on + # a mid-tree node walks old_depth+1 levels and marks direct + # children as visited — they then get no expand chevrons. + node.depth = 0 + with suppress_graph_traversal_warnings(): + if not self._state.traverse_from_node(unique_id, depth=1): + parent_item.emitDataChanged() + return False + + # Avoid full-graph tier BFS on every expand; new rows use parent+1. + pcm = build_parent_child_map(self._state.nodes, self._state.edges) + child_nodes = [ + self._state.nodes[uid] + for uid in pcm.get(unique_id, []) + if uid not in self._uid_to_item and uid in self._state.nodes + ] + child_nodes.sort(key=lambda n: abs(n.cumulative_score), reverse=True) + for child_node in child_nodes: + self._add_node(child_node, parent_item, pcm, recurse_known=False) + + if self.has_real_children(parent_item): + if not self._batch_updating: + self.column_max_changed.emit() + return True + + parent_item.emitDataChanged() + return False + finally: + self._expanding = False + + def restrict_to_uids(self, keep: set[int]) -> None: + """Remove rows whose unique_id is not in ``keep`` (deepest first). + + Used when restoring a cached view after path/cumulative display-set + restrict (or any filter that left a subset of the traversal in the model). + """ + if self._state is None: + return + to_remove = [uid for uid in self._uid_to_item if uid not in keep] + to_remove.sort( + key=lambda u: int(self._uid_to_item[u].data(TIER_ROLE) or 0), + reverse=True, + ) + for uid in to_remove: + item = self._uid_to_item.get(uid) + if item is None: + continue + parent = item.parent() + if parent is None: + parent = self.invisibleRootItem() + row = item.row() + self._forget_subtree(item) + parent.removeRow(row) + + pcm = build_parent_child_map(self._state.nodes, self._state.edges) + for uid, item in list(self._uid_to_item.items()): + if self.has_real_children(item): + continue + if self._has_hidden_children(uid, pcm): + self._ensure_placeholder(item) + + def _forget_subtree(self, item: QtGui.QStandardItem) -> None: + for row in range(item.rowCount()): + child = item.child(row, 0) + if child is not None and not child.data(PLACEHOLDER_ROLE): + self._forget_subtree(child) + uid = item.data(UID_ROLE) + if uid is not None: + self._uid_to_item.pop(uid, None) + + def _has_hidden_children(self, unique_id: int, pcm: dict | None = None) -> bool: + if self._state is None: + return False + if pcm is None: + pcm = build_parent_child_map(self._state.nodes, self._state.edges) + return any( + cid not in self._uid_to_item and cid in self._state.nodes + for cid in pcm.get(unique_id, []) + ) + + def to_dataframe(self, metadata_lookup=None): + """Return a flat DataFrame of all traversed nodes.""" + if self._state is None: + import pandas as pd + return pd.DataFrame(columns=COLUMNS) + return flatten_to_dataframe( + self._state.nodes, + self._state.edges, + self._total_score, + metadata_lookup=metadata_lookup, + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _add_node( + self, + node, + parent_item: QtGui.QStandardItem, + pcm: dict, + *, + recurse_known: bool = True, + ) -> None: + """Create a row of QStandardItems for ``node`` under ``parent_item``.""" + if node.unique_id in self._uid_to_item: + return + + meta = self._resolve_meta(node) + total = self._total_score + # Tier from edge distance to FU — never Brightway node.depth after lazy expand + tier = self._tiers.get(node.unique_id) + if tier is None: + if parent_item is self.invisibleRootItem(): + tier = 0 + else: + parent_tier = parent_item.data(TIER_ROLE) + tier = (int(parent_tier) + 1) if parent_tier is not None else 0 + + cum_pct = cumulative_percent(node, total) + dir_pct = direct_percent(node, total) + + def _item(text, value=None, numeric=False): + it = QtGui.QStandardItem() + it.setText(str(text)) + it.setEditable(False) + if value is not None: + it.setData(value, ImpactBackgroundDelegate.VALUE_ROLE) + if numeric and isinstance(value, float): + it.setData(value, QtCore.Qt.UserRole) + return it + + row = [ + _item(f"{cum_pct:.2f}", value=cum_pct, numeric=True), + _item(f"{dir_pct:.2f}", value=dir_pct, numeric=True), + _item(meta.get("product", "")), + _item(meta.get("name", "")), + _item(meta.get("location", "")), + _item(meta.get("database", "")), + _item(f"{node.supply_amount:.4g}"), + _item(meta.get("unit", "")), + _item(f"{node.cumulative_score:.4g}", value=node.cumulative_score, numeric=True), + _item( + f"{node.direct_emissions_score:.4g}", + value=node.direct_emissions_score, + numeric=True, + ), + _item(str(tier)), + ] + + is_visited = node.unique_id in (self._state.visited_nodes if self._state else set()) + has_children = bool(pcm.get(node.unique_id)) + is_leaf = is_visited and not has_children + if not is_visited and tier > 0: + row[COL_PROCESS].setForeground(QtGui.QBrush(QtGui.QColor("#888888"))) + row[COL_PROCESS].setToolTip("Not yet expanded — click to explore") + + if is_leaf: + for item in row: + font = item.font() + font.setItalic(True) + item.setFont(font) + + parent_item.appendRow(row) + first = row[COL_CUMULATIVE_PCT] + first.setData(node.unique_id, UID_ROLE) + first.setData(tier, TIER_ROLE) + self._uid_to_item[node.unique_id] = first + + self._update_col_max(COL_CUMULATIVE_PCT, abs(cum_pct)) + self._update_col_max(COL_DIRECT_PCT, abs(dir_pct)) + self._update_col_max(COL_CUMULATIVE, abs(node.cumulative_score)) + self._update_col_max(COL_DIRECT, abs(node.direct_emissions_score)) + + if recurse_known: + known_children = pcm.get(node.unique_id, []) + child_nodes = [ + self._state.nodes[uid] + for uid in known_children + if self._state and uid in self._state.nodes and uid not in self._uid_to_item + ] + child_nodes.sort(key=lambda n: abs(n.cumulative_score), reverse=True) + for child_node in child_nodes: + self._add_node(child_node, first, pcm, recurse_known=True) + + # Chevron when not yet listing children: unvisited (lazy), or visited with + # known edges not shown under this row (e.g. prior over-deep traverse). + if not self.has_real_children(first) and (not is_visited or has_children): + self._ensure_placeholder(first) + + def _resolve_meta(self, node) -> dict: + """Fetch activity metadata from bw2data (cached; empty dict on failure).""" + aid = getattr(node, "activity_datapackage_id", None) + if aid in self._meta_cache: + return self._meta_cache[aid] + try: + act = bd.get_node(id=aid) + meta = { + "product": act.get("reference product") or act.get("name", ""), + "name": act.get("name", ""), + "location": act.get("location", ""), + "database": act.get("database", ""), + "unit": act.get("unit", ""), + } + except Exception: + meta = {} + if aid is not None: + self._meta_cache[aid] = meta + return meta + + def _update_col_max(self, col: int, value: float) -> None: + if value > self.col_max.get(col, 0.0): + self.col_max[col] = value + diff --git a/activity_browser/app/pages/lca_results/contribution_tree_plot.py b/activity_browser/app/pages/lca_results/contribution_tree_plot.py new file mode 100644 index 000000000..35c23ac7c --- /dev/null +++ b/activity_browser/app/pages/lca_results/contribution_tree_plot.py @@ -0,0 +1,147 @@ +"""Sunburst plot for the Contribution Tree tab.""" + +from __future__ import annotations + +from typing import Optional + +from bw_graph_tools.graph_traversal import SameNodeEachVisitGraphTraversal + +from activity_browser.bwutils.contribution_tree import build_sunburst_rings +from activity_browser.ui import widgets + +class SunburstPlot(widgets.ABPlot): + """Layered donut chart showing the contribution tree by tier. + + Ring construction: one ring per tier (depth 1…plot_depth). Each wedge's + angular width = child.cumulative_score / parent.cumulative_score. An + "other" wedge fills the remainder where the traversal was pruned. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.plot_name = "Contribution Tree" + self._state: Optional[SameNodeEachVisitGraphTraversal] = None + self._total_score: float = 0.0 + self._plot_depth: int = 3 + + def set_state( + self, + state: SameNodeEachVisitGraphTraversal, + total_score: float, + plot_depth: int = 3, + ) -> None: + self._state = state + self._total_score = total_score + self._plot_depth = plot_depth + self.plot() + + def update_depth(self, plot_depth: int) -> None: + self._plot_depth = plot_depth + self.plot() + + def plot(self) -> None: + if self._state is None or self._total_score == 0.0: + self.figure.clear() + self.canvas.draw_idle() + return + + rings = build_sunburst_rings( + self._state.nodes, + self._state.edges, + self._total_score, + max_depth=self._plot_depth, + ) + if not rings: + self.figure.clear() + self.canvas.draw_idle() + return + + self.figure.clear() + ax = self.figure.add_subplot(111, polar=True) + ax.set_theta_zero_location("N") + ax.set_theta_direction(-1) + ax.set_axis_off() + + n_rings = len(rings) + ring_width = 1.0 / (n_rings + 1) # leave space for centre label + + import numpy as np + import matplotlib + + cmap = matplotlib.colormaps["tab20c"] + + for ring_idx, ring in enumerate(rings): + bottom = ring_width * (ring_idx + 1) + + # Track angular position for each parent + # We need to lay out wedges respecting parent arc positions. + # Build per-parent wedge lists + by_parent: dict = {} + for w in ring: + by_parent.setdefault(w["parent_unique_id"], []).append(w) + + # For tier-1 ring: parent is root, arc starts at 0, full circle + # For deeper rings: use parent wedge start angles (stored per uid) + if ring_idx == 0: + parent_starts = {list(by_parent.keys())[0]: 0.0} + parent_spans = {list(by_parent.keys())[0]: 2 * np.pi} + else: + parent_starts = getattr(self, "_wedge_starts", {}) + parent_spans = getattr(self, "_wedge_spans", {}) + + new_starts: dict = {} + new_spans: dict = {} + + for parent_uid, wedges in by_parent.items(): + p_start = parent_starts.get(parent_uid, 0.0) + p_span = parent_spans.get(parent_uid, 2 * np.pi) + + theta = p_start + for i, w in enumerate(wedges): + arc = w["share"] * p_span + colour = ( + (0.7, 0.7, 0.7, 0.5) + if w["is_other"] + else cmap((ring_idx * 7 + i) % 20 / 20) + ) + ax.bar( + x=theta, + width=arc, + bottom=bottom, + height=ring_width * 0.9, + color=colour, + edgecolor="white", + linewidth=0.5, + align="edge", + ) + if not w["is_other"] and arc > 0.2: + label = str(w.get("label", ""))[:20] + mid = theta + arc / 2 + ax.text( + mid, + bottom + ring_width * 0.45, + label, + ha="center", + va="center", + fontsize=6, + rotation=0, + clip_on=True, + ) + new_starts[w["unique_id"]] = theta + new_spans[w["unique_id"]] = arc + theta += arc + + self._wedge_starts = new_starts + self._wedge_spans = new_spans + + # Centre label + ax.text( + 0, 0, + f"Tier {self._plot_depth}", + ha="center", va="center", + fontsize=8, + transform=ax.transData, + ) + + self.finish_plot() + diff --git a/activity_browser/app/pages/lca_results/contribution_tree_tab.py b/activity_browser/app/pages/lca_results/contribution_tree_tab.py index 2d415a370..070a7d957 100644 --- a/activity_browser/app/pages/lca_results/contribution_tree_tab.py +++ b/activity_browser/app/pages/lca_results/contribution_tree_tab.py @@ -2,15 +2,13 @@ Shows the contribution tree as a hierarchical QTreeView (one row per traversed upstream supplier) with a sunburst plot above it. - -Tickets implemented here: 03, 04, 05, 06, 07, 08, 09, 10–13. """ from __future__ import annotations import time from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Optional +from typing import Optional import bw2data as bd import bw2calc as bc @@ -25,68 +23,35 @@ from activity_browser import app from activity_browser.bwutils.contribution_tree import ( - build_parent_child_map, compute_node_tiers, cumulative_percent, direct_impact_coverage, - direct_percent, plan_cumulative_expand, path_display_set, - build_sunburst_rings, - flatten_to_dataframe, next_expand_candidates, suppress_graph_traversal_warnings, ) from activity_browser.bwutils.export_names import lca_export_basename -from activity_browser.ui import widgets from activity_browser.ui.delegates.impact_background import ImpactBackgroundDelegate from .combobox_utils import configure_scenario_widgets, scenario_labels, update_combobox +from .contribution_tree_model import ( + BAR_COLUMNS, + COL_CUMULATIVE, + COL_CUMULATIVE_PCT, + COL_DIRECT, + COL_DIRECT_PCT, + COL_PROCESS, + EXPAND_MODE_CUMULATIVE, + EXPAND_MODE_PATH, + EXPAND_MODE_TIER, + TIER_ROLE, + UID_ROLE, + ContributionTreeModel, +) +from .contribution_tree_plot import SunburstPlot from .style import SmallComboBox, apply_lca_combo_width, lca_header_layout, lca_help_tool_button, lca_tab_control_row -if TYPE_CHECKING: - pass - -# Column indices — keep in sync with COLUMNS list -COL_CUMULATIVE_PCT = 0 -COL_DIRECT_PCT = 1 -COL_PRODUCT = 2 -COL_PROCESS = 3 -COL_LOCATION = 4 -COL_DATABASE = 5 -COL_FLOW_AMOUNT = 6 -COL_UNIT = 7 -COL_CUMULATIVE = 8 -COL_DIRECT = 9 -COL_TIER = 10 - -COLUMNS = [ - "Cumulative impact (%)", - "Direct impact (%)", - "Product", - "Process", - "Location", - "Database", - "Flow amount", - "Unit", - "Cumulative impact", - "Direct impact", - "Tier", -] - -# Columns that get the impact-background delegate (signed magnitude values) -BAR_COLUMNS = (COL_CUMULATIVE_PCT, COL_DIRECT_PCT, COL_CUMULATIVE, COL_DIRECT) - -EXPAND_MODE_TIER = "tier" -EXPAND_MODE_PATH = "path" -EXPAND_MODE_CUMULATIVE = "cumulative" - -# Role for contribution-tree node unique_id on the first-column item -UID_ROLE = QtCore.Qt.UserRole + 1 -PLACEHOLDER_ROLE = QtCore.Qt.UserRole + 2 -TIER_ROLE = QtCore.Qt.UserRole + 3 - - HELP_TEXT = """ Contribution Tree shows how impact accumulates along the supply chain @@ -150,525 +115,7 @@ class ContributionTreeCacheEntry: # --------------------------------------------------------------------------- -# Tree item model (Ticket 03) -# --------------------------------------------------------------------------- - -class ContributionTreeModel(QtGui.QStandardItemModel): - """QStandardItemModel backed by a SameNodeEachVisitGraphTraversal state. - - Populated lazily: call ``load_state`` after initial traversal, then - ``expand_node`` from a queued ``expanded`` handler. Empty placeholder - children provide expand chevrons without visible ellipsis text. - """ - - column_max_changed = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(0, len(COLUMNS), parent) - self.setHorizontalHeaderLabels(COLUMNS) - self._state: Optional[SameNodeEachVisitGraphTraversal] = None - self._total_score: float = 0.0 - self._root_uid: int | None = None - self._tiers: dict[int, int] = {} - # Maps unique_id → QStandardItem (the first-column item for that row) - self._uid_to_item: dict[int, QtGui.QStandardItem] = {} - # Column max values for the bar-background delegates - self.col_max: dict[int, float] = {c: 1.0 for c in BAR_COLUMNS} - self._expanding: bool = False - self._meta_cache: dict = {} - self._batch_updating: bool = False - - @staticmethod - def _has_real_children(item: QtGui.QStandardItem) -> bool: - for row in range(item.rowCount()): - child = item.child(row, 0) - if child is not None and not child.data(PLACEHOLDER_ROLE): - return True - return False - - def _strip_placeholders(self, parent_item: QtGui.QStandardItem) -> None: - for row in range(parent_item.rowCount() - 1, -1, -1): - child = parent_item.child(row, 0) - if child is not None and child.data(PLACEHOLDER_ROLE): - parent_item.removeRow(row) - - def _ensure_placeholder(self, first: QtGui.QStandardItem) -> None: - """Empty child so the view shows a chevron (no visible ellipsis text).""" - if first.rowCount() > 0: - return - ph = QtGui.QStandardItem("") - ph.setEditable(False) - ph.setData(True, PLACEHOLDER_ROLE) - ph.setFlags(QtCore.Qt.ItemFlag.NoItemFlags) - first.appendRow( - [ph] + [QtGui.QStandardItem("") for _ in range(len(COLUMNS) - 1)] - ) - - def _refresh_tiers(self) -> None: - if self._state is None or self._root_uid is None: - self._tiers = {} - return - self._tiers = compute_node_tiers( - self._state.nodes, self._state.edges, self._root_uid - ) - - # ------------------------------------------------------------------ - # Public API - # ------------------------------------------------------------------ - - def load_state( - self, - state: SameNodeEachVisitGraphTraversal, - total_score: float, - ) -> None: - """Rebuild the model from a (possibly cached) traversal state.""" - self.clear() - self.setHorizontalHeaderLabels(COLUMNS) - self._state = state - self._total_score = total_score - self._uid_to_item = {} - self.col_max = {c: 1.0 for c in BAR_COLUMNS} - self._meta_cache = {} - self._root_uid = state._root_node.unique_id - self._refresh_tiers() - - pcm = build_parent_child_map(state.nodes, state.edges) - root_children = [ - state.nodes[uid] - for uid in pcm.get(self._root_uid, []) - if uid in state.nodes - ] - root_children.sort(key=lambda n: abs(n.cumulative_score), reverse=True) - self._batch_updating = True - try: - for child in root_children: - self._add_node(child, self.invisibleRootItem(), pcm) - finally: - self._batch_updating = False - - def expand_node( - self, - unique_id: int, - min_path_pct: float | None = None, - ) -> bool: - """Traverse from the given node and add its direct children to the model. - - All children discovered by graph traversal are listed (the engine cutoff - already limits which edges exist). ``min_path_pct`` is ignored for - listing — it only affects auto-expand policy elsewhere. - """ - if self._state is None or self._expanding: - return False - - parent_item = self._uid_to_item.get(unique_id) - if parent_item is None: - return False - - self._expanding = True - try: - self._strip_placeholders(parent_item) - - if unique_id not in self._state.visited_nodes: - node = self._state.nodes.get(unique_id) - if node is None: - parent_item.emitDataChanged() - return False - # Brightway computes max_depth from node.depth *before* resetting - # depth to 0. Without zeroing here, traverse_from_node(depth=1) on - # a mid-tree node walks old_depth+1 levels and marks direct - # children as visited — they then get no expand chevrons. - node.depth = 0 - with suppress_graph_traversal_warnings(): - if not self._state.traverse_from_node(unique_id, depth=1): - parent_item.emitDataChanged() - return False - - # Avoid full-graph tier BFS on every expand; new rows use parent+1. - pcm = build_parent_child_map(self._state.nodes, self._state.edges) - child_nodes = [ - self._state.nodes[uid] - for uid in pcm.get(unique_id, []) - if uid not in self._uid_to_item and uid in self._state.nodes - ] - child_nodes.sort(key=lambda n: abs(n.cumulative_score), reverse=True) - for child_node in child_nodes: - self._add_node(child_node, parent_item, pcm, recurse_known=False) - - if self._has_real_children(parent_item): - if not self._batch_updating: - self.column_max_changed.emit() - return True - - parent_item.emitDataChanged() - return False - finally: - self._expanding = False - - def prune_below_threshold(self, min_path_pct: float) -> None: - """Drop rows whose path (cumulative) impact is below ``min_path_pct``. - - Kept for rare callers; individual path expand no longer uses this — - siblings below the expand threshold stay listed under open parents. - """ - if self._state is None: - return - to_remove = [ - uid - for uid, item in self._uid_to_item.items() - if (node := self._state.nodes.get(uid)) is not None - and int(item.data(TIER_ROLE) or 0) > 0 - and abs(cumulative_percent(node, self._total_score)) < min_path_pct - ] - to_remove.sort( - key=lambda u: int(self._uid_to_item[u].data(TIER_ROLE) or 0), - reverse=True, - ) - for uid in to_remove: - item = self._uid_to_item.get(uid) - if item is None: - continue - parent = item.parent() - if parent is None: - parent = self.invisibleRootItem() - row = item.row() - self._forget_subtree(item) - parent.removeRow(row) - - pcm = build_parent_child_map(self._state.nodes, self._state.edges) - for uid, item in self._uid_to_item.items(): - if self._has_real_children(item): - continue - if self._has_hidden_children(uid, pcm): - self._ensure_placeholder(item) - - def restrict_to_uids(self, keep: set[int]) -> None: - """Remove rows whose unique_id is not in ``keep`` (deepest first). - - Used when restoring a cached view after path-impact prune (or any - other filter that left a subset of the traversal in the model). - """ - if self._state is None: - return - to_remove = [uid for uid in self._uid_to_item if uid not in keep] - to_remove.sort( - key=lambda u: int(self._uid_to_item[u].data(TIER_ROLE) or 0), - reverse=True, - ) - for uid in to_remove: - item = self._uid_to_item.get(uid) - if item is None: - continue - parent = item.parent() - if parent is None: - parent = self.invisibleRootItem() - row = item.row() - self._forget_subtree(item) - parent.removeRow(row) - - pcm = build_parent_child_map(self._state.nodes, self._state.edges) - for uid, item in list(self._uid_to_item.items()): - if self._has_real_children(item): - continue - if self._has_hidden_children(uid, pcm): - self._ensure_placeholder(item) - - def _forget_subtree(self, item: QtGui.QStandardItem) -> None: - for row in range(item.rowCount()): - child = item.child(row, 0) - if child is not None and not child.data(PLACEHOLDER_ROLE): - self._forget_subtree(child) - uid = item.data(UID_ROLE) - if uid is not None: - self._uid_to_item.pop(uid, None) - - def _has_hidden_children(self, unique_id: int, pcm: dict | None = None) -> bool: - if self._state is None: - return False - if pcm is None: - pcm = build_parent_child_map(self._state.nodes, self._state.edges) - return any( - cid not in self._uid_to_item and cid in self._state.nodes - for cid in pcm.get(unique_id, []) - ) - - def to_dataframe(self, metadata_lookup=None): - """Return a flat DataFrame of all traversed nodes.""" - if self._state is None: - import pandas as pd - return pd.DataFrame(columns=COLUMNS) - return flatten_to_dataframe( - self._state.nodes, - self._state.edges, - self._total_score, - metadata_lookup=metadata_lookup, - ) - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _add_node( - self, - node, - parent_item: QtGui.QStandardItem, - pcm: dict, - *, - recurse_known: bool = True, - ) -> None: - """Create a row of QStandardItems for ``node`` under ``parent_item``.""" - if node.unique_id in self._uid_to_item: - return - - meta = self._resolve_meta(node) - total = self._total_score - # Tier from edge distance to FU — never Brightway node.depth after lazy expand - tier = self._tiers.get(node.unique_id) - if tier is None: - if parent_item is self.invisibleRootItem(): - tier = 0 - else: - parent_tier = parent_item.data(TIER_ROLE) - tier = (int(parent_tier) + 1) if parent_tier is not None else 0 - - cum_pct = cumulative_percent(node, total) - dir_pct = direct_percent(node, total) - - def _item(text, value=None, numeric=False): - it = QtGui.QStandardItem() - it.setText(str(text)) - it.setEditable(False) - if value is not None: - it.setData(value, ImpactBackgroundDelegate.VALUE_ROLE) - if numeric and isinstance(value, float): - it.setData(value, QtCore.Qt.UserRole) - return it - - row = [ - _item(f"{cum_pct:.2f}", value=cum_pct, numeric=True), - _item(f"{dir_pct:.2f}", value=dir_pct, numeric=True), - _item(meta.get("product", "")), - _item(meta.get("name", "")), - _item(meta.get("location", "")), - _item(meta.get("database", "")), - _item(f"{node.supply_amount:.4g}"), - _item(meta.get("unit", "")), - _item(f"{node.cumulative_score:.4g}", value=node.cumulative_score, numeric=True), - _item( - f"{node.direct_emissions_score:.4g}", - value=node.direct_emissions_score, - numeric=True, - ), - _item(str(tier)), - ] - - is_visited = node.unique_id in (self._state.visited_nodes if self._state else set()) - has_children = bool(pcm.get(node.unique_id)) - is_leaf = is_visited and not has_children - if not is_visited and tier > 0: - row[COL_PROCESS].setForeground(QtGui.QBrush(QtGui.QColor("#888888"))) - row[COL_PROCESS].setToolTip("Not yet expanded — click to explore") - - if is_leaf: - for item in row: - font = item.font() - font.setItalic(True) - item.setFont(font) - - parent_item.appendRow(row) - first = row[COL_CUMULATIVE_PCT] - first.setData(node.unique_id, UID_ROLE) - first.setData(tier, TIER_ROLE) - self._uid_to_item[node.unique_id] = first - - self._update_col_max(COL_CUMULATIVE_PCT, abs(cum_pct)) - self._update_col_max(COL_DIRECT_PCT, abs(dir_pct)) - self._update_col_max(COL_CUMULATIVE, abs(node.cumulative_score)) - self._update_col_max(COL_DIRECT, abs(node.direct_emissions_score)) - - if recurse_known: - known_children = pcm.get(node.unique_id, []) - child_nodes = [ - self._state.nodes[uid] - for uid in known_children - if self._state and uid in self._state.nodes and uid not in self._uid_to_item - ] - child_nodes.sort(key=lambda n: abs(n.cumulative_score), reverse=True) - for child_node in child_nodes: - self._add_node(child_node, first, pcm, recurse_known=True) - - # Chevron when not yet listing children: unvisited (lazy), or visited with - # known edges not shown under this row (e.g. prior over-deep traverse). - if not self._has_real_children(first) and (not is_visited or has_children): - self._ensure_placeholder(first) - - def _resolve_meta(self, node) -> dict: - """Fetch activity metadata from bw2data (cached; empty dict on failure).""" - aid = getattr(node, "activity_datapackage_id", None) - if aid in self._meta_cache: - return self._meta_cache[aid] - try: - act = bd.get_node(id=aid) - meta = { - "product": act.get("reference product") or act.get("name", ""), - "name": act.get("name", ""), - "location": act.get("location", ""), - "database": act.get("database", ""), - "unit": act.get("unit", ""), - } - except Exception: - meta = {} - if aid is not None: - self._meta_cache[aid] = meta - return meta - - def _update_col_max(self, col: int, value: float) -> None: - if value > self.col_max.get(col, 0.0): - self.col_max[col] = value - - -# --------------------------------------------------------------------------- -# Sunburst plot (Ticket 05) -# --------------------------------------------------------------------------- - -class SunburstPlot(widgets.ABPlot): - """Layered donut chart showing the contribution tree by tier. - - Ring construction: one ring per tier (depth 1…plot_depth). Each wedge's - angular width = child.cumulative_score / parent.cumulative_score. An - "other" wedge fills the remainder where the traversal was pruned. - """ - - def __init__(self, parent=None): - super().__init__(parent) - self.plot_name = "Contribution Tree" - self._state: Optional[SameNodeEachVisitGraphTraversal] = None - self._total_score: float = 0.0 - self._plot_depth: int = 3 - - def set_state( - self, - state: SameNodeEachVisitGraphTraversal, - total_score: float, - plot_depth: int = 3, - ) -> None: - self._state = state - self._total_score = total_score - self._plot_depth = plot_depth - self.plot() - - def update_depth(self, plot_depth: int) -> None: - self._plot_depth = plot_depth - self.plot() - - def plot(self) -> None: - if self._state is None or self._total_score == 0.0: - self.figure.clear() - self.canvas.draw_idle() - return - - rings = build_sunburst_rings( - self._state.nodes, - self._state.edges, - self._total_score, - max_depth=self._plot_depth, - ) - if not rings: - self.figure.clear() - self.canvas.draw_idle() - return - - self.figure.clear() - ax = self.figure.add_subplot(111, polar=True) - ax.set_theta_zero_location("N") - ax.set_theta_direction(-1) - ax.set_axis_off() - - n_rings = len(rings) - ring_width = 1.0 / (n_rings + 1) # leave space for centre label - - import numpy as np - import matplotlib - - cmap = matplotlib.colormaps["tab20c"] - - for ring_idx, ring in enumerate(rings): - bottom = ring_width * (ring_idx + 1) - - # Track angular position for each parent - # We need to lay out wedges respecting parent arc positions. - # Build per-parent wedge lists - by_parent: dict = {} - for w in ring: - by_parent.setdefault(w["parent_unique_id"], []).append(w) - - # For tier-1 ring: parent is root, arc starts at 0, full circle - # For deeper rings: use parent wedge start angles (stored per uid) - if ring_idx == 0: - parent_starts = {list(by_parent.keys())[0]: 0.0} - parent_spans = {list(by_parent.keys())[0]: 2 * np.pi} - else: - parent_starts = getattr(self, "_wedge_starts", {}) - parent_spans = getattr(self, "_wedge_spans", {}) - - new_starts: dict = {} - new_spans: dict = {} - - for parent_uid, wedges in by_parent.items(): - p_start = parent_starts.get(parent_uid, 0.0) - p_span = parent_spans.get(parent_uid, 2 * np.pi) - - theta = p_start - for i, w in enumerate(wedges): - arc = w["share"] * p_span - colour = ( - (0.7, 0.7, 0.7, 0.5) - if w["is_other"] - else cmap((ring_idx * 7 + i) % 20 / 20) - ) - ax.bar( - x=theta, - width=arc, - bottom=bottom, - height=ring_width * 0.9, - color=colour, - edgecolor="white", - linewidth=0.5, - align="edge", - ) - if not w["is_other"] and arc > 0.2: - label = str(w.get("label", ""))[:20] - mid = theta + arc / 2 - ax.text( - mid, - bottom + ring_width * 0.45, - label, - ha="center", - va="center", - fontsize=6, - rotation=0, - clip_on=True, - ) - new_starts[w["unique_id"]] = theta - new_spans[w["unique_id"]] = arc - theta += arc - - self._wedge_starts = new_starts - self._wedge_spans = new_spans - - # Centre label - ax.text( - 0, 0, - f"Tier {self._plot_depth}", - ha="center", va="center", - fontsize=8, - transform=ax.transData, - ) - - self.finish_plot() - - -# --------------------------------------------------------------------------- -# Main tab widget (Tickets 04, 06, 07, 08, 09) +# Per-selection cache entry lives above; tab widget below. # --------------------------------------------------------------------------- class ContributionTreeTab(QtWidgets.QWidget): @@ -972,10 +419,9 @@ def _update_calculation_setup(self, cs_name: str = None) -> None: w.blockSignals(False) return - import bw2data as _bd - setup = _bd.calculation_setups.get(cs, {}) + setup = bd.calculation_setups.get(cs, {}) fu_acts = [ - list({_bd.get_activity(k): v for k, v in fu.items()}.keys())[0] + list({bd.get_activity(k): v for k, v in fu.items()}.keys())[0] for fu in setup.get("inv", []) ] self.fu_cb.clear() @@ -1005,14 +451,13 @@ def _traversal_cutoff(self) -> float: def _selection_inputs(self, key: tuple): """Resolve demand dict and method tuple for a cache key.""" - import bw2data as _bd fu_idx, method_idx, scenario_idx, _cutoff_pct = key cs = self.parent.cs_name - setup = _bd.calculation_setups[cs] + setup = bd.calculation_setups[cs] demand_raw = setup["inv"][fu_idx] method = setup["ia"][method_idx] - demand = {_bd.get_activity(k).id: v for k, v in demand_raw.items()} + demand = {bd.get_activity(k).id: v for k, v in demand_raw.items()} return demand, method, scenario_idx def _ensure_lca(self, demand: dict, method, scenario_idx, method_idx: int | None = None) -> None: @@ -1022,7 +467,6 @@ def _ensure_lca(self, demand: dict, method, scenario_idx, method_idx: int | None RF without ``redo_lci`` leaves ``state.lca.score`` belonging to another demand, which breaks coverage checks and further ``traverse_from_node``. """ - import bw2data as _bd if self.has_scenarios and scenario_idx is not None: mi = method_idx if method_idx is not None else self.method_cb.currentIndex() @@ -1031,7 +475,7 @@ def _ensure_lca(self, demand: dict, method, scenario_idx, method_idx: int | None ) if self._cached_lca is None: - fu_input, data_objs, _ = _bd.prepare_lca_inputs(demand=demand, method=method) + fu_input, data_objs, _ = bd.prepare_lca_inputs(demand=demand, method=method) self._cached_lca = bc.LCA(demand=fu_input, data_objs=data_objs) self._cached_lca.lci(factorize=True) self._cached_lca.lcia() @@ -1104,8 +548,6 @@ def _run_traversal(self) -> None: progress = self._busy_dialog("Calculating contribution tree…") try: - import bw2data as _bd - self._busy_tick(progress, "Running LCI / LCIA…") self._ensure_lca(demand, method, scenario_idx, method_idx) @@ -1117,7 +559,7 @@ def _run_traversal(self) -> None: ) with suppress_graph_traversal_warnings(): state.traverse(depth=2) - state.metadata = {"unit": _bd.methods[method].get("unit", "")} + state.metadata = {"unit": bd.methods[method].get("unit", "")} self._store_total_score(state) logger.debug(f"Traversal done in {time.time()-t0:.2f}s") @@ -1169,7 +611,7 @@ def _reload_from_state( def _collect_expanded_uids(self) -> set[int]: """Return unique_ids of rows currently expanded in the tree view.""" expanded: set[int] = set() - for uid, item in self._tree_model._uid_to_item.items(): + for uid, item in self._tree_model.iter_uid_items(): idx = self._tree_model.indexFromItem(item) if idx.isValid() and self._tree_view.isExpanded(idx): expanded.add(uid) @@ -1183,8 +625,8 @@ def _restore_expanded_uids(self, uids: set[int]) -> None: try: to_expand: list[tuple[int, QtGui.QStandardItem]] = [] for uid in uids: - item = self._tree_model._uid_to_item.get(uid) - if item is None or not self._tree_model._has_real_children(item): + item = self._tree_model.item_for_uid(uid) + if item is None or not self._tree_model.has_real_children(item): continue to_expand.append((int(item.data(TIER_ROLE) or 0), item)) to_expand.sort(key=lambda pair: pair[0]) @@ -1202,7 +644,7 @@ def _save_view_snapshot(self) -> None: return entry = self._cache[key] entry.expanded_uids = self._collect_expanded_uids() - entry.model_uids = set(self._tree_model._uid_to_item.keys()) + entry.model_uids = self._tree_model.model_uids() # ------------------------------------------------------------------ # Slot handlers @@ -1445,8 +887,8 @@ def _apply_expand_view_state( """Collapse, then open calculated branches according to the expand policy. * Tier: open rows with real children whose display tier is ``< max_tier``. - * Individual path impact: open only nodes whose path impact still meets - the threshold (after pruning smaller branches). + * Individual path impact: prefer ``path_display_set`` + restore expands + on the Expand button path; this helper is mainly for Tier. * Cumulative: open visited nodes that have real children. """ state = self._current_state @@ -1457,8 +899,8 @@ def _apply_expand_view_state( try: self._tree_view.collapseAll() to_expand: list[tuple[int, QtGui.QStandardItem]] = [] - for uid, item in self._tree_model._uid_to_item.items(): - if not self._tree_model._has_real_children(item): + for uid, item in self._tree_model.iter_uid_items(): + if not self._tree_model.has_real_children(item): continue tier = item.data(TIER_ROLE) tier_i = int(tier) if tier is not None else 0 @@ -1571,7 +1013,7 @@ def _on_row_expanded(self, index: QtCore.QModelIndex) -> None: # cumulative "only as many siblings as needed" leftovers). Safe when # the row already has some children — collapse/re-expand reveals rest. added = self._tree_model.expand_node(uid) - if added or self._tree_model._has_real_children(first_col_item): + if added or self._tree_model.has_real_children(first_col_item): self._update_delegate_maxima() self._reload_plot() self._update_footer_stats() @@ -1632,12 +1074,12 @@ def _update_footer_stats(self) -> None: if state is None: self._stats_label.setText("") return - root_uid = self._tree_model._root_uid + root_uid = self._tree_model.root_uid total = self._state_total_score(state) shown_cov = self._visible_direct_impact_coverage() shown_n = sum( 1 - for item in self._tree_model._uid_to_item.values() + for _, item in self._tree_model.iter_uid_items() if self._is_row_visible(item) ) calc_cov = direct_impact_coverage(state.nodes, total, root_uid) @@ -1684,7 +1126,7 @@ def _visible_direct_impact_coverage(self) -> float: if total == 0.0: return 0.0 direct_sum = 0.0 - for uid, item in self._tree_model._uid_to_item.items(): + for uid, item in self._tree_model.iter_uid_items(): if not self._is_row_visible(item): continue node = state.nodes.get(uid) @@ -1696,7 +1138,7 @@ def _visible_direct_impact_coverage(self) -> float: def _max_visible_tier(self) -> int: """Deepest tier among rows whose ancestor chain is expanded in the view.""" max_tier = 0 - for item in self._tree_model._uid_to_item.values(): + for _, item in self._tree_model.iter_uid_items(): if self._is_row_visible(item): max_tier = max(max_tier, int(item.data(TIER_ROLE) or 0)) return max_tier @@ -1706,7 +1148,7 @@ def _max_calculated_tier(self) -> int: state = self._current_state if state is None: return 0 - root_uid = self._tree_model._root_uid + root_uid = self._tree_model.root_uid if root_uid is None: return 0 tiers = compute_node_tiers(state.nodes, state.edges, root_uid) diff --git a/activity_browser/bwutils/contribution_tree.py b/activity_browser/bwutils/contribution_tree.py index 3f8d684cd..e4c960af0 100644 --- a/activity_browser/bwutils/contribution_tree.py +++ b/activity_browser/bwutils/contribution_tree.py @@ -10,13 +10,10 @@ import warnings from contextlib import contextmanager -from typing import TYPE_CHECKING, Callable +from typing import Callable import pandas as pd -if TYPE_CHECKING: - pass - # --------------------------------------------------------------------------- # Type aliases (kept simple to avoid Qt / bw imports at module level) @@ -120,7 +117,9 @@ def compute_node_tiers( # Expand policy / footer stats # --------------------------------------------------------------------------- -EXPAND_MODES = ("tier", "path", "cumulative") +# Modes accepted by :func:`next_expand_candidates` (traversal probing only). +# Cumulative expand uses :func:`plan_cumulative_expand` instead. +CANDIDATE_EXPAND_MODES = ("tier", "path") def _visible_contribution_nodes( @@ -313,15 +312,19 @@ def next_expand_candidates( exclude: set | None = None, eligible_ids: set | None = None, ) -> list[NodeId]: - """Return unique_ids that the expand policy should open next. + """Return unique_ids still needing ``traverse_from_node`` for tier/path. + + Cumulative impact expand must use :func:`plan_cumulative_expand` (display + set + remaining-upstream ranking). Individual path *display* / visual + expand uses :func:`path_display_set`; this helper only probes which + unvisited nodes to calculate so the display set can be built. Parameters ---------- mode: - ``"tier"``, ``"path"``, or ``"cumulative"``. + ``"tier"`` or ``"path"`` only. value: - For tier: maximum tier (int). For path / cumulative: percent - of |total| (0–100 UI scale). + For tier: maximum tier (int). For path: percent of |total| (0–100). visited: ``state.visited_nodes`` — nodes already traversed. root_uid: @@ -330,19 +333,17 @@ def next_expand_candidates( exclude: Ids to skip (e.g. already failed to expand). eligible_ids: - If set, only consider these ids (e.g. rows already in the tree model). - Important for cumulative mode so we do not pick a globally largest - unvisited node that is not yet in the view and cannot be expanded. + If set, only consider these ids. Returns ------- - For ``tier`` / ``path``: all matching unvisited ids (any order). - For ``cumulative``: at most one id (largest abs cumulative among - unvisited), or ``[]`` when coverage already meets the target or nothing - remains to expand. + Matching unvisited ids (any order). """ - if mode not in EXPAND_MODES: - raise ValueError(f"Unknown expand mode: {mode!r}") + if mode not in CANDIDATE_EXPAND_MODES: + raise ValueError( + f"Unknown expand mode: {mode!r} " + f"(use plan_cumulative_expand for cumulative)" + ) skip = exclude or set() unvisited = [ @@ -367,28 +368,15 @@ def next_expand_candidates( if tiers.get(n.unique_id, max_tier + 1) < max_tier ] - if mode == "path": - if total_score == 0.0: - return [] - threshold = abs(total_score) * (value / 100.0) - return [ - n.unique_id - for n in unvisited - if abs(getattr(n, "cumulative_score", 0.0)) >= threshold - ] - - # cumulative — one step, largest-first among eligible unvisited - coverage = direct_impact_coverage(nodes, total_score, root_uid) - if coverage >= (value / 100.0) or not unvisited: + # path — calculate nodes at/above threshold; display uses path_display_set + if total_score == 0.0: return [] - best = max( - unvisited, - key=lambda n: ( - abs(getattr(n, "cumulative_score", 0.0)), - -n.unique_id, - ), - ) - return [best.unique_id] + threshold = abs(total_score) * (value / 100.0) + return [ + n.unique_id + for n in unvisited + if abs(getattr(n, "cumulative_score", 0.0)) >= threshold + ] def path_display_set( diff --git a/tests/test_contribution_tree.py b/tests/test_contribution_tree.py index 6dbdd178a..86497926e 100644 --- a/tests/test_contribution_tree.py +++ b/tests/test_contribution_tree.py @@ -398,82 +398,13 @@ def test_path_does_not_auto_expand_children_below_threshold(): assert 12 not in cands -def test_cumulative_skips_excluded_and_prefers_eligible(): - """Largest global unvisited is skipped if excluded / not eligible (not in view).""" - nodes = { - -1: _node(-1, 0, 100.0, 0.0), - 1: _node(1, 1, 80.0, 1.0), # largest, but not eligible - 2: _node(2, 1, 50.0, 1.0), - } - edges = [_edge(-1, 1), _edge(-1, 2)] - cands = next_expand_candidates( - nodes, - edges, - {-1}, - mode="cumulative", - value=90.0, - total_score=100.0, - eligible_ids={2}, - ) - assert cands == [2] - cands2 = next_expand_candidates( - nodes, - edges, - {-1}, - mode="cumulative", - value=90.0, - total_score=100.0, - exclude={1}, - ) - assert cands2 == [2] - - -def test_cumulative_tie_break_prefers_lower_unique_id(): - nodes = { - -1: _node(-1, 0, 100.0, 0.0), - 5: _node(5, 1, 40.0, 1.0), - 2: _node(2, 1, 40.0, 1.0), - } - edges = [_edge(-1, 5), _edge(-1, 2)] - cands = next_expand_candidates( - nodes, edges, {-1}, mode="cumulative", value=50.0, total_score=100.0 - ) - assert cands == [2] - - -def test_cumulative_returns_largest_unvisited_until_coverage(): - nodes, edges = _visible_nodes() - visited = {-1} - # coverage of visible directs = 0.35; target 50% → need to expand - cands = next_expand_candidates( - nodes, edges, visited, mode="cumulative", value=50.0, total_score=100.0 - ) - # One step: largest unvisited by abs cumulative = node 1 (50) - assert cands == [1] - - -def test_cumulative_stops_when_coverage_met(): - nodes = { - -1: _node(-1, 0, 100.0, 0.0), - 1: _node(1, 1, 50.0, 80.0), - 2: _node(2, 1, 30.0, 10.0), - } - edges = [_edge(-1, 1), _edge(-1, 2)] - visited = {-1} - cands = next_expand_candidates( - nodes, edges, visited, mode="cumulative", value=80.0, total_score=100.0 - ) - # visible directs already 90 >= 80% - assert cands == [] - - -def test_cumulative_empty_when_nothing_left(): +def test_next_expand_candidates_rejects_cumulative_mode(): + """Cumulative expand must use plan_cumulative_expand, not this helper.""" nodes, edges = _visible_nodes() - visited = set(nodes) - cands = next_expand_candidates( - nodes, edges, visited, mode="cumulative", value=99.0, total_score=100.0 - ) - assert cands == [] + with pytest.raises(ValueError, match="plan_cumulative_expand"): + next_expand_candidates( + nodes, edges, {-1}, mode="cumulative", value=50.0, total_score=100.0 + ) def test_plan_cumulative_stops_near_target_not_all_nodes(): From 7d0d312745b3bd4bb6c12a31989b3f513faf4a5c Mon Sep 17 00:00:00 2001 From: bsteubing
+ +Date: Wed, 12 Aug 2026 09:10:46 +0200 Subject: [PATCH 08/10] Third, refined working version of Contribution Tree tab --- .../lca_results/contribution_tree_model.py | 26 +-- .../lca_results/contribution_tree_plot.py | 1 + .../lca_results/contribution_tree_tab.py | 189 ++---------------- activity_browser/bwutils/contribution_tree.py | 151 ++++++++++---- 4 files changed, 143 insertions(+), 224 deletions(-) diff --git a/activity_browser/app/pages/lca_results/contribution_tree_model.py b/activity_browser/app/pages/lca_results/contribution_tree_model.py index baa059c8e..f8dde0959 100644 --- a/activity_browser/app/pages/lca_results/contribution_tree_model.py +++ b/activity_browser/app/pages/lca_results/contribution_tree_model.py @@ -15,7 +15,7 @@ cumulative_percent, direct_percent, flatten_to_dataframe, - suppress_graph_traversal_warnings, + safe_traverse_from_node, ) from activity_browser.ui.delegates.impact_background import ImpactBackgroundDelegate @@ -171,16 +171,11 @@ def load_state( finally: self._batch_updating = False - def expand_node( - self, - unique_id: int, - min_path_pct: float | None = None, - ) -> bool: + def expand_node(self, unique_id: int) -> bool: """Traverse from the given node and add its direct children to the model. All children discovered by graph traversal are listed (the engine cutoff - already limits which edges exist). ``min_path_pct`` is ignored for - listing — it only affects auto-expand policy elsewhere. + already limits which edges exist). """ if self._state is None or self._expanding: return False @@ -194,21 +189,10 @@ def expand_node( self._strip_placeholders(parent_item) if unique_id not in self._state.visited_nodes: - node = self._state.nodes.get(unique_id) - if node is None: + if not safe_traverse_from_node(self._state, unique_id): parent_item.emitDataChanged() return False - # Brightway computes max_depth from node.depth *before* resetting - # depth to 0. Without zeroing here, traverse_from_node(depth=1) on - # a mid-tree node walks old_depth+1 levels and marks direct - # children as visited — they then get no expand chevrons. - node.depth = 0 - with suppress_graph_traversal_warnings(): - if not self._state.traverse_from_node(unique_id, depth=1): - parent_item.emitDataChanged() - return False - - # Avoid full-graph tier BFS on every expand; new rows use parent+1. + pcm = build_parent_child_map(self._state.nodes, self._state.edges) child_nodes = [ self._state.nodes[uid] diff --git a/activity_browser/app/pages/lca_results/contribution_tree_plot.py b/activity_browser/app/pages/lca_results/contribution_tree_plot.py index 35c23ac7c..dff7c1138 100644 --- a/activity_browser/app/pages/lca_results/contribution_tree_plot.py +++ b/activity_browser/app/pages/lca_results/contribution_tree_plot.py @@ -50,6 +50,7 @@ def plot(self) -> None: self._state.edges, self._total_score, max_depth=self._plot_depth, + root_uid=self._state._root_node.unique_id, ) if not rings: self.figure.clear() diff --git a/activity_browser/app/pages/lca_results/contribution_tree_tab.py b/activity_browser/app/pages/lca_results/contribution_tree_tab.py index 070a7d957..c089417dc 100644 --- a/activity_browser/app/pages/lca_results/contribution_tree_tab.py +++ b/activity_browser/app/pages/lca_results/contribution_tree_tab.py @@ -24,11 +24,8 @@ from activity_browser import app from activity_browser.bwutils.contribution_tree import ( compute_node_tiers, - cumulative_percent, direct_impact_coverage, - plan_cumulative_expand, - path_display_set, - next_expand_candidates, + run_expand_policy, suppress_graph_traversal_warnings, ) from activity_browser.bwutils.export_names import lca_export_basename @@ -719,55 +716,37 @@ def _on_expand_clicked(self) -> None: self._ensure_lca(demand, method, scenario_idx, key[1]) self._store_total_score(state) total = self._state_total_score(state) - root_uid = state._root_node.unique_id progress = self._busy_dialog("Expanding contribution tree…") self._last_expand_target_pct = ( value if mode in (EXPAND_MODE_PATH, EXPAND_MODE_CUMULATIVE) else None ) try: - cumulative_included: set[int] | None = None - cumulative_expand: set[int] | None = None - path_included: set[int] | None = None - path_expand: set[int] | None = None - - if mode == EXPAND_MODE_CUMULATIVE: - self._busy_tick(progress, "Traversing supply chain…") - cumulative_included, cumulative_expand = ( - self._expand_cumulative_brightway(value, progress) + def _tick(step, n_nodes): + self._busy_tick( + progress, f"Traversing supply chain… ({n_nodes} nodes)" ) - else: - self._busy_tick(progress, "Traversing supply chain…") - self._expand_policy_brightway(mode, value, progress) - if mode == EXPAND_MODE_PATH: - path_included, path_expand = path_display_set( - state.nodes, - state.edges, - root_uid, - total, - value, - state.visited_nodes, - ) - - # Rebuild tree once from traversal state (already-known nodes are free) + + self._busy_tick(progress, "Traversing supply chain…") + included, to_expand = run_expand_policy( + state, + mode=mode, + value=value, + total_score=total, + on_progress=_tick, + ) + self._busy_tick(progress, "Building tree…") self._tree_model.load_state(state, total) - if mode == EXPAND_MODE_PATH and path_included is not None: - # Keep high-path nodes + all their siblings; drop unrelated deep cache - self._tree_model.restrict_to_uids(path_included) - elif mode == EXPAND_MODE_CUMULATIVE and cumulative_included is not None: - # Show only the largest-first set that meets the target — not - # every node ever calculated in this RF's cached graph. - self._tree_model.restrict_to_uids(cumulative_included) + if included is not None: + self._tree_model.restrict_to_uids(included) self._busy_tick(progress, "Updating tree view…") self._update_delegate_maxima() if mode == EXPAND_MODE_TIER: self._apply_expand_view_state(max_tier=int(value)) - elif mode == EXPAND_MODE_PATH and path_expand is not None: - self._restore_expanded_uids(path_expand) - elif cumulative_expand is not None: - self._restore_expanded_uids(cumulative_expand) + elif to_expand is not None: + self._restore_expanded_uids(to_expand) if self.show_plot_cb.isChecked(): self._busy_tick(progress, "Updating plot…") self._reload_plot() @@ -778,123 +757,8 @@ def _on_expand_clicked(self) -> None: progress.close() progress.deleteLater() - def _expand_cumulative_brightway( - self, - target_pct: float, - progress: QtWidgets.QProgressDialog, - ) -> tuple[set[int], set[int]]: - """Largest-first from RFs until display-set coverage meets ``target_pct``. - - Reuses already-calculated edges when possible; only calls - ``traverse_from_node`` when the next node to open is still unvisited. - Returns ``(included_uids, visually_expanded_uids)``. - """ - state = self._current_state - assert state is not None - total = self._state_total_score(state) - root_uid = state._root_node.unique_id - failed: set[int] = set() - included: set[int] = set() - to_expand: set[int] = set() - - for step in range(10_000): - included, to_expand, need = plan_cumulative_expand( - state.nodes, - state.edges, - root_uid, - total, - target_pct, - state.visited_nodes, - exclude=failed, - ) - if need is None: - break - if step % 10 == 0: - self._busy_tick( - progress, - f"Traversing supply chain… ({len(state.nodes)} nodes)", - ) - node = state.nodes.get(need) - if node is None or need in state.visited_nodes: - failed.add(need) - continue - node.depth = 0 - with suppress_graph_traversal_warnings(): - if not state.traverse_from_node(need, depth=1): - failed.add(need) - - return included, to_expand - - def _expand_policy_brightway( - self, - mode: str, - value: float, - progress: QtWidgets.QProgressDialog, - ) -> None: - """Run tier/path expand policy against Brightway state only. - - Cumulative mode uses :meth:`_expand_cumulative_brightway` instead. - """ - state = self._current_state - if state is None: - return - total = self._state_total_score(state) - root_uid = state._root_node.unique_id - failed: set[int] = set() - - for step in range(10_000): - candidates = next_expand_candidates( - state.nodes, - state.edges, - state.visited_nodes, - mode=mode, - value=value, - total_score=total, - root_uid=root_uid, - exclude=failed, - ) - if not candidates: - break - - if step % 10 == 0: - self._busy_tick( - progress, - f"Traversing supply chain… ({len(state.nodes)} nodes)", - ) - - made_progress = False - for uid in candidates: - node = state.nodes.get(uid) - if node is None or uid in state.visited_nodes: - failed.add(uid) - continue - node.depth = 0 - with suppress_graph_traversal_warnings(): - if state.traverse_from_node(uid, depth=1): - made_progress = True - else: - failed.add(uid) - if not made_progress: - break - - def _apply_expand_view_state( - self, - max_tier: int | None = None, - *, - min_path_pct: float | None = None, - only_visited: bool = False, - ) -> None: - """Collapse, then open calculated branches according to the expand policy. - - * Tier: open rows with real children whose display tier is ``< max_tier``. - * Individual path impact: prefer ``path_display_set`` + restore expands - on the Expand button path; this helper is mainly for Tier. - * Cumulative: open visited nodes that have real children. - """ - state = self._current_state - total = self._state_total_score(state) if state is not None else 0.0 - visited = state.visited_nodes if state is not None else set() - + def _apply_expand_view_state(self, max_tier: int) -> None: + """Collapse, then open rows with real children whose display tier is ``< max_tier``.""" self._suppress_expand_handler = True try: self._tree_view.collapseAll() @@ -902,18 +766,9 @@ def _apply_expand_view_state( for uid, item in self._tree_model.iter_uid_items(): if not self._tree_model.has_real_children(item): continue - tier = item.data(TIER_ROLE) - tier_i = int(tier) if tier is not None else 0 - if max_tier is not None and tier_i >= max_tier: - continue - if only_visited and uid not in visited: + tier_i = int(item.data(TIER_ROLE) or 0) + if tier_i >= max_tier: continue - if min_path_pct is not None and state is not None: - node = state.nodes.get(uid) - if node is None: - continue - if abs(cumulative_percent(node, total)) < min_path_pct: - continue to_expand.append((tier_i, item)) to_expand.sort(key=lambda pair: pair[0]) for _, item in to_expand: diff --git a/activity_browser/bwutils/contribution_tree.py b/activity_browser/bwutils/contribution_tree.py index e4c960af0..9916a3b17 100644 --- a/activity_browser/bwutils/contribution_tree.py +++ b/activity_browser/bwutils/contribution_tree.py @@ -379,6 +379,101 @@ def next_expand_candidates( ] +def safe_traverse_from_node(state, unique_id: NodeId, depth: int = 1) -> bool: + """Zero ``node.depth``, suppress coverage warnings, then ``traverse_from_node``. + + Brightway derives relative max depth from the current ``node.depth`` before + resetting it; mid-tree expands must start at depth 0 so ``depth=1`` means + one edge. + """ + if unique_id in state.visited_nodes: + return False + node = state.nodes.get(unique_id) + if node is None: + return False + node.depth = 0 + with suppress_graph_traversal_warnings(): + return bool(state.traverse_from_node(unique_id, depth=depth)) + + +def run_expand_policy( + state, + *, + mode: str, + value: float, + total_score: float, + on_progress=None, +) -> tuple[set[NodeId] | None, set[NodeId] | None]: + """Traverse for an expand policy; return display-set ``(included, to_expand)``. + + For ``tier`` / ``path``: traverse via :func:`next_expand_candidates`. + For ``path``: also return :func:`path_display_set`. + For ``cumulative``: loop :func:`plan_cumulative_expand` until done. + + ``on_progress(step, n_nodes)`` is optional (e.g. UI busy tick). + Returns ``(None, None)`` for tier (caller opens view by max tier). + """ + root_uid = state._root_node.unique_id + failed: set[NodeId] = set() + + if mode == "cumulative": + included: set[NodeId] = set() + to_expand: set[NodeId] = set() + for step in range(10_000): + included, to_expand, need = plan_cumulative_expand( + state.nodes, + state.edges, + root_uid, + total_score, + value, + state.visited_nodes, + exclude=failed, + ) + if need is None: + break + if on_progress and step % 10 == 0: + on_progress(step, len(state.nodes)) + if not safe_traverse_from_node(state, need): + failed.add(need) + return included, to_expand + + # tier / path — calculate first + for step in range(10_000): + candidates = next_expand_candidates( + state.nodes, + state.edges, + state.visited_nodes, + mode=mode, + value=value, + total_score=total_score, + root_uid=root_uid, + exclude=failed, + ) + if not candidates: + break + if on_progress and step % 10 == 0: + on_progress(step, len(state.nodes)) + made_progress = False + for uid in candidates: + if safe_traverse_from_node(state, uid): + made_progress = True + else: + failed.add(uid) + if not made_progress: + break + + if mode == "path": + return path_display_set( + state.nodes, + state.edges, + root_uid, + total_score, + value, + state.visited_nodes, + ) + return None, None + + def path_display_set( nodes: dict, edges: list, @@ -443,57 +538,42 @@ def build_sunburst_rings( edges: list, total_score: float, max_depth: int, + root_uid: NodeId | None = None, ) -> list[list[dict]]: - """Build per-depth ring data for a sunburst (layered donut) chart. + """Build per-tier ring data for a sunburst (layered donut) chart. - Returns a list of rings, one per depth level from 1 to ``max_depth``. - Each ring is a list of wedge dicts:: + Rings use **display tiers** (RF = 0), not Brightway's mutable ``node.depth``. + ``max_depth`` is the number of rings (tiers ``0 .. max_depth-1``). - { - "unique_id": int, - "label": str, # activity name or "other" - "share": float, # fraction of *parent* arc (0–1) - "cumulative_score": float, - "is_other": bool, - } - - Wedge ``share`` is ``node.cumulative_score / parent.cumulative_score``. - An ``"other"`` wedge is appended when the children's shares don't sum to 1. - - Parameters - ---------- - nodes: - ``state.nodes`` dict. - edges: - ``state.edges`` list. - total_score: - ``lca.score`` — used only to guard against zero; not used for ring math. - max_depth: - Maximum tier depth to include (inclusive). + Each ring is a list of wedge dicts with ``unique_id``, ``label``, ``share``, + ``cumulative_score``, ``parent_unique_id``, ``is_other``. """ if not nodes or total_score == 0.0: return [] - parent_child = build_parent_child_map(nodes, edges) + if root_uid is None: + roots = [n for n in nodes.values() if getattr(n, "depth", None) == 0] + if len(roots) != 1: + return [] + root_uid = roots[0].unique_id - # Collect nodes by depth - by_depth: dict[int, list] = {} + tiers = compute_node_tiers(nodes, edges, root_uid) + by_tier: dict[int, list] = {} for node in nodes.values(): - d = node.depth - if 1 <= d <= max_depth: - by_depth.setdefault(d, []).append(node) + if node.unique_id == root_uid: + continue + t = tiers.get(node.unique_id) + if t is not None and 0 <= t < max_depth: + by_tier.setdefault(t, []).append(node) rings: list[list[dict]] = [] - - for depth in range(1, max_depth + 1): - depth_nodes = by_depth.get(depth, []) + for tier in range(0, max_depth): + depth_nodes = by_tier.get(tier, []) if not depth_nodes: break - # Group by parent to compute "other" wedge per parent by_parent: dict[NodeId, list] = {} for node in depth_nodes: - # Find this node's parent via edges parent_id = _find_parent(node.unique_id, edges) by_parent.setdefault(parent_id, []).append(node) @@ -518,7 +598,6 @@ def build_sunburst_rings( "is_other": False, }) - # "other" wedge for the remainder remainder = parent_score - children_score_sum if abs(remainder) > abs(parent_score) * 1e-9: ring.append({ From 173d31cd2b458018fa0cedca18fd52be94c71d62 Mon Sep 17 00:00:00 2001 From: bsteubing Date: Wed, 12 Aug 2026 17:31:39 +0200 Subject: [PATCH 09/10] Fourth, refined working version of Contribution Tree tab. Now with interactive plots. Work on plots, tables or both. --- CONTEXT.md | 20 +- .../app/pages/lca_results/LCA_results.py | 2 +- .../lca_results/contribution_tree_model.py | 87 +- .../lca_results/contribution_tree_plot.py | 877 ++++++++++++++++-- .../lca_results/contribution_tree_tab.py | 381 +++++--- activity_browser/bwutils/contribution_tree.py | 438 ++++++++- activity_browser/ui/widgets/plot.py | 21 + tests/test_contribution_tree.py | 223 ++++- tests/test_contribution_tree_plot.py | 70 ++ 9 files changed, 1834 insertions(+), 285 deletions(-) create mode 100644 tests/test_contribution_tree_plot.py diff --git a/CONTEXT.md b/CONTEXT.md index 88a807257..4838ea3ac 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -125,7 +125,7 @@ Extensibility mechanism for third-party AB features. **Architecture TBD** — do ### Contribution tree -A hierarchical, acyclic breakdown of LCA impact by upstream supplier, produced by priority-first graph traversal (`SameNodeEachVisitGraphTraversal`). Each node carries a **cumulative impact** (its own direct emissions plus all upstream) and a **direct impact** (its own biosphere flows only). The root is the functional unit; children are direct technosphere suppliers, recursed up the supply chain. Shown in AB as a `QTreeView` with one row per traversed node, in the "Contribution Tree" tab of the LCA Results page. Nodes are calculated lazily on expand; an **expand policy** controls how far auto-expand walks. +A hierarchical, acyclic breakdown of LCA impact by upstream supplier, produced by priority-first graph traversal (`SameNodeEachVisitGraphTraversal`). Each node carries a **cumulative impact** (its own direct emissions plus all upstream) and a **direct impact** (its own biosphere flows only). The root is the functional unit; children are direct technosphere suppliers, recursed up the supply chain. Shown in AB as a `QTreeView` with one row per traversed node, in the "Contribution Tree" tab of the LCA Results page. Nodes are calculated lazily on expand; the **adjust policy** controls how far the Adjust control walks the tree. _Avoid_: supply-chain tree, upstream tree (use contribution tree in AB UI; "upstream tree" is the OpenLCA term for the same concept) ### Tier (contribution-tree depth) @@ -156,13 +156,23 @@ _Avoid_: traversal coverage, score coverage (unless clearly meaning this ratio) ### Path impact -The cumulative impact of a contribution-tree node as a share of the total LCA score — i.e. how much of the result flows through that supply-chain path. Shown as **Cumulative impact (%)**. The **Individual path impact** expand policy auto-opens nodes at/above a chosen path % only while a child at/above that % remains (terminal high-path nodes stay collapsed); under opened nodes it lists all discovered siblings. Only the engine traversal **cutoff** omits smaller branches from calculation. +The cumulative impact of a contribution-tree node as a share of the total LCA score — i.e. how much of the result flows through that supply-chain path. Shown as **Cumulative impact (%)**. The **Individual path impact** adjust policy auto-opens nodes at/above a chosen path % only while a child at/above that % remains (terminal high-path nodes stay collapsed); under opened nodes it lists all discovered siblings. Only the engine traversal **cutoff** omits smaller branches from calculation. _Avoid_: individual impact (alone), branch score -### Expand policy +### Adjust policy -How far auto-expand calculates and visually opens the contribution tree. Modes: **Tier** (open down to a given tier), **Individual path impact** (keep expanding while path impact ≥ X% continues into a child; list all discovered children under opened nodes; leave terminal ≥ X% rows collapsed), **Cumulative impact** (largest-first from the reference flow until the **display set**’s direct-impact coverage reaches a target %, capped below 100% — does not open every previously calculated node). Distinct from a later optional **display filter** that only hides already-calculated rows. Open branches and which rows are in the tree are remembered per RF / impact category / scenario / cutoff when switching selections in the Contribution Tree tab. -_Avoid_: cutoff (alone — ambiguous with Process Contributions and engine traversal cutoff) +How far the **Adjust to** control calculates and visually opens the contribution tree. Modes: **Tier** (open down to a given tier), **Individual path impact** (keep expanding while path impact ≥ X% continues into a child; list all discovered children under opened nodes; leave terminal ≥ X% rows collapsed), **Cumulative impact** (largest-first from the reference flow until the **display set**’s direct-impact coverage reaches a target %, capped below 100% — does not open every previously calculated node). Distinct from a later optional **display filter** that only hides already-calculated rows. Open branches and which rows are in the tree are remembered per RF / impact category / scenario / cutoff when switching selections in the Contribution Tree tab. +_Avoid_: cutoff (alone — ambiguous with Process Contributions and engine traversal cutoff); expand policy (legacy UI label — use adjust policy) + +### Plot–tree linking + +Clicking a segment in a contribution-tree plot selects the corresponding row and expands or collapses that branch in the tree (or the parent row when the segment is an aggregate band). **Terminal** segments (no downstream suppliers after traversal) are **expand-only** from the plot — one expand attempt if collapsed, otherwise no-op. Non-terminal segments toggle expand/collapse. The plot refreshes to match the visible tree. +_Avoid_: interactive chart (alone — specify plot–tree linking) + +### Plot aggregation + +Plot-only rollup of **sibling** segments under the same parent by a metadata field (Product, Process, Location, Unit, Database). Band width and direct-impact tint use summed impacts; the tree table is unchanged. +_Avoid_: aggregate the contribution tree (alone — plot aggregation is plot-only in v1) ### Flow amount diff --git a/activity_browser/app/pages/lca_results/LCA_results.py b/activity_browser/app/pages/lca_results/LCA_results.py index e92887e65..4bca571d2 100644 --- a/activity_browser/app/pages/lca_results/LCA_results.py +++ b/activity_browser/app/pages/lca_results/LCA_results.py @@ -143,7 +143,7 @@ def __init__(self, cs_name, mlca, contributions, mc, parent=None): results="LCA scores", ef="EF Contributions", process="Process Contributions", - contribution_tree="Contribution Tree", + contribution_tree="Tree", sankey="Sankey", mc="Monte Carlo", gsa="Sensitivity Analysis", diff --git a/activity_browser/app/pages/lca_results/contribution_tree_model.py b/activity_browser/app/pages/lca_results/contribution_tree_model.py index f8dde0959..2173c6d9f 100644 --- a/activity_browser/app/pages/lca_results/contribution_tree_model.py +++ b/activity_browser/app/pages/lca_results/contribution_tree_model.py @@ -2,6 +2,7 @@ from __future__ import annotations +from types import SimpleNamespace from typing import Optional import bw2data as bd @@ -83,6 +84,8 @@ def __init__(self, parent=None): self._expanding: bool = False self._meta_cache: dict = {} self._batch_updating: bool = False + self._pcm_cache: dict | None = None + self._pcm_node_count: int = 0 @staticmethod def has_real_children(item: QtGui.QStandardItem) -> bool: @@ -145,32 +148,59 @@ def load_state( self, state: SameNodeEachVisitGraphTraversal, total_score: float, + included_uids: set[int] | None = None, ) -> None: - """Rebuild the model from a (possibly cached) traversal state.""" + """Rebuild the model from a (possibly cached) traversal state. + + When ``included_uids`` is set, only those nodes are materialized + (avoids building the full tree then pruning). + """ self.clear() self.setHorizontalHeaderLabels(COLUMNS) self._state = state self._total_score = total_score self._uid_to_item = {} self.col_max = {c: 1.0 for c in BAR_COLUMNS} - self._meta_cache = {} self._root_uid = state._root_node.unique_id + self._invalidate_pcm_cache() self._refresh_tiers() - pcm = build_parent_child_map(state.nodes, state.edges) + pcm = self._parent_child_map() root_children = [ state.nodes[uid] for uid in pcm.get(self._root_uid, []) if uid in state.nodes + and (included_uids is None or uid in included_uids) ] root_children.sort(key=lambda n: abs(n.cumulative_score), reverse=True) + self._prefetch_meta_for_uids(included_uids, state.nodes) self._batch_updating = True try: for child in root_children: - self._add_node(child, self.invisibleRootItem(), pcm) + self._add_node( + child, + self.invisibleRootItem(), + pcm, + included_uids=included_uids, + ) finally: self._batch_updating = False + def _parent_child_map(self) -> dict: + if self._state is None: + return {} + n = len(self._state.nodes) + if self._pcm_cache is not None and self._pcm_node_count == n: + return self._pcm_cache + pcm = build_parent_child_map(self._state.nodes, self._state.edges) + self._pcm_cache = pcm + self._pcm_node_count = n + return pcm + + def _invalidate_pcm_cache(self) -> None: + self._pcm_cache = None + self._pcm_node_count = 0 + def expand_node(self, unique_id: int) -> bool: """Traverse from the given node and add its direct children to the model. @@ -192,8 +222,9 @@ def expand_node(self, unique_id: int) -> bool: if not safe_traverse_from_node(self._state, unique_id): parent_item.emitDataChanged() return False + self._invalidate_pcm_cache() - pcm = build_parent_child_map(self._state.nodes, self._state.edges) + pcm = self._parent_child_map() child_nodes = [ self._state.nodes[uid] for uid in pcm.get(unique_id, []) @@ -237,7 +268,7 @@ def restrict_to_uids(self, keep: set[int]) -> None: self._forget_subtree(item) parent.removeRow(row) - pcm = build_parent_child_map(self._state.nodes, self._state.edges) + pcm = self._parent_child_map() for uid, item in list(self._uid_to_item.items()): if self.has_real_children(item): continue @@ -286,10 +317,13 @@ def _add_node( pcm: dict, *, recurse_known: bool = True, + included_uids: set[int] | None = None, ) -> None: """Create a row of QStandardItems for ``node`` under ``parent_item``.""" if node.unique_id in self._uid_to_item: return + if included_uids is not None and node.unique_id not in included_uids: + return meta = self._resolve_meta(node) total = self._total_score @@ -335,17 +369,10 @@ def _item(text, value=None, numeric=False): is_visited = node.unique_id in (self._state.visited_nodes if self._state else set()) has_children = bool(pcm.get(node.unique_id)) - is_leaf = is_visited and not has_children if not is_visited and tier > 0: row[COL_PROCESS].setForeground(QtGui.QBrush(QtGui.QColor("#888888"))) row[COL_PROCESS].setToolTip("Not yet expanded — click to explore") - if is_leaf: - for item in row: - font = item.font() - font.setItalic(True) - item.setFont(font) - parent_item.appendRow(row) first = row[COL_CUMULATIVE_PCT] first.setData(node.unique_id, UID_ROLE) @@ -366,13 +393,45 @@ def _item(text, value=None, numeric=False): ] child_nodes.sort(key=lambda n: abs(n.cumulative_score), reverse=True) for child_node in child_nodes: - self._add_node(child_node, first, pcm, recurse_known=True) + if included_uids is not None and child_node.unique_id not in included_uids: + continue + self._add_node( + child_node, + first, + pcm, + recurse_known=True, + included_uids=included_uids, + ) # Chevron when not yet listing children: unvisited (lazy), or visited with # known edges not shown under this row (e.g. prior over-deep traverse). if not self.has_real_children(first) and (not is_visited or has_children): self._ensure_placeholder(first) + def _prefetch_meta_for_uids( + self, + uids: set[int] | None, + nodes: dict, + ) -> None: + """Warm metadata cache for a filtered display set before building rows.""" + if uids is None: + return + for uid in uids: + node = nodes.get(uid) + if node is None: + continue + aid = getattr(node, "activity_datapackage_id", None) + if aid is not None and aid not in self._meta_cache: + self._resolve_meta(node) + + def lookup_activity_meta(self, activity_datapackage_id) -> dict: + """Public metadata lookup for plot tooltips (by activity id).""" + if activity_datapackage_id is None: + return {} + return self._resolve_meta( + SimpleNamespace(activity_datapackage_id=activity_datapackage_id) + ) + def _resolve_meta(self, node) -> dict: """Fetch activity metadata from bw2data (cached; empty dict on failure).""" aid = getattr(node, "activity_datapackage_id", None) diff --git a/activity_browser/app/pages/lca_results/contribution_tree_plot.py b/activity_browser/app/pages/lca_results/contribution_tree_plot.py index dff7c1138..02af739f2 100644 --- a/activity_browser/app/pages/lca_results/contribution_tree_plot.py +++ b/activity_browser/app/pages/lca_results/contribution_tree_plot.py @@ -1,148 +1,825 @@ -"""Sunburst plot for the Contribution Tree tab.""" +"""Contribution Tree plots — tier-stacked bars and icicle (sunburst retained, not in UI).""" from __future__ import annotations -from typing import Optional +import textwrap +from typing import Callable, Optional +import matplotlib.pyplot as plt +from matplotlib import colors as mcolors +import numpy as np from bw_graph_tools.graph_traversal import SameNodeEachVisitGraphTraversal +from matplotlib.patches import Rectangle -from activity_browser.bwutils.contribution_tree import build_sunburst_rings +from activity_browser.bwutils.contribution_tree import ( + PLOT_AGGREGATE_LABELS, + build_plot_segments, + direct_impact_intensity, + direct_impact_rgba, +) from activity_browser.ui import widgets -class SunburstPlot(widgets.ABPlot): - """Layered donut chart showing the contribution tree by tier. +PLOT_SUNBURST = "sunburst" +PLOT_TIER_BARS = "tier_bars" +PLOT_ICICLE = "icicle" - Ring construction: one ring per tier (depth 1…plot_depth). Each wedge's - angular width = child.cumulative_score / parent.cumulative_score. An - "other" wedge fills the remainder where the traversal was pruned. +# Sunburst kept in code but hidden from the UI for now. +PLOT_MODES_ALL = ( + (PLOT_SUNBURST, "Sunburst"), + (PLOT_TIER_BARS, "Tier-stacked bars"), + (PLOT_ICICLE, "Icicle"), +) + +PLOT_MODES = ( + (PLOT_TIER_BARS, "Tier-stacked bars"), + (PLOT_ICICLE, "Icicle"), +) + +# Re-applied after theme sync (ABPlot otherwise sets edges to axes facecolor). +SEGMENT_EDGE_COLOR = "white" +SEGMENT_EDGE_WIDTH = 0.25 + + +class ContributionTreePlot(widgets.ABPlot): + """Supply-chain plots for the Contribution Tree tab. + + All modes share ``build_chain_layout`` (parent-aligned segments), + direct-impact colour tinting, product-only labels, and rich hover tooltips. """ def __init__(self, parent=None): super().__init__(parent) self.plot_name = "Contribution Tree" + self._mode = PLOT_ICICLE self._state: Optional[SameNodeEachVisitGraphTraversal] = None self._total_score: float = 0.0 self._plot_depth: int = 3 + self._unit: str = "" + self._segments: list[dict] = [] + self._max_direct_pct: float = 100.0 + self._hover_targets: list[tuple] = [] + self._included_uids: set[int] | None = None + self._aggregate_by: str | None = None + self._on_segment_clicked: Callable[[dict], None] | None = None + self.show_empty() + + @staticmethod + def _theme_text_color(*, muted: bool = False) -> str: + color = plt.rcParams["text.color"] + if not muted: + return color + return mcolors.to_rgba(color, alpha=0.65) + + @staticmethod + def _is_dark_theme() -> bool: + face = plt.rcParams["axes.facecolor"] + if face in ("none", "None"): + face = plt.rcParams["figure.facecolor"] + r, g, b = mcolors.to_rgb(face) + return (0.299 * r + 0.587 * g + 0.114 * b) < 0.45 + + def _segment_color(self, direct_pct: float) -> tuple[float, float, float, float]: + """Segment fill — boosted for readability on dark plot backgrounds.""" + r, g, b, a = direct_impact_rgba(direct_pct, self._max_direct_pct) + if not self._is_dark_theme(): + return (r, g, b, a) + intensity = direct_impact_intensity(direct_pct, self._max_direct_pct) + rgb = np.array([r, g, b], dtype=float) + target_lum = 0.38 + 0.34 * intensity + lum = float(rgb @ np.array([0.299, 0.587, 0.114])) + if lum < target_lum: + rgb = np.clip(rgb * (target_lum / max(lum, 1e-6)), 0.0, 1.0) + a = 0.55 + 0.45 * intensity + return (float(rgb[0]), float(rgb[1]), float(rgb[2]), float(a)) + + def _apply_segment_borders(self) -> None: + for ax in self.figure.axes: + for patch in ax.patches: + patch.set_edgecolor(SEGMENT_EDGE_COLOR) + patch.set_linewidth(SEGMENT_EDGE_WIDTH) + + def _sync_plot_to_theme(self) -> None: + super()._sync_plot_to_theme() + self._apply_segment_borders() + + def show_empty(self, message: str | None = None) -> None: + """Blank placeholder — no default matplotlib axes.""" + self._hover_targets = [] + self.figure.clear() + ax = self.figure.add_subplot(111) + ax.set_axis_off() + if message: + ax.text( + 0.5, + 0.5, + message, + ha="center", + va="center", + transform=ax.transAxes, + fontsize=9, + color=self._theme_text_color(muted=True), + ) + self.finish_plot(on_hover=None, on_click=None) + + def set_segment_click_handler( + self, handler: Callable[[dict], None] | None + ) -> None: + """Register tab callback invoked with the clicked segment dict.""" + self._on_segment_clicked = handler + + def set_mode(self, mode: str) -> None: + if mode == self._mode: + return + self._mode = mode + if self._state is not None: + self.plot() def set_state( self, state: SameNodeEachVisitGraphTraversal, total_score: float, plot_depth: int = 3, + metadata_lookup: Callable[[int], dict] | None = None, + unit: str = "", + included_uids: set[int] | None = None, + aggregate_by: str | None = None, ) -> None: self._state = state self._total_score = total_score self._plot_depth = plot_depth + self._unit = unit or "" + self._included_uids = included_uids + self._aggregate_by = aggregate_by + self._segments = build_plot_segments( + state.nodes, + state.edges, + total_score, + max_depth=plot_depth, + root_uid=state._root_node.unique_id, + metadata_lookup=metadata_lookup, + included_uids=included_uids, + aggregate_by=aggregate_by, + ) + if self._segments: + self._max_direct_pct = max( + abs(s["direct_pct"]) for s in self._segments + ) or 100.0 + else: + self._max_direct_pct = 100.0 self.plot() def update_depth(self, plot_depth: int) -> None: - self._plot_depth = plot_depth - self.plot() + """Refresh segments when tree depth changes (no separate UI control).""" + self._plot_depth = max(1, plot_depth) + if self._state is not None: + self.set_state( + self._state, + self._total_score, + self._plot_depth, + metadata_lookup=None, + unit=self._unit, + included_uids=self._included_uids, + aggregate_by=self._aggregate_by, + ) + + @staticmethod + def _segment_label(seg: dict) -> str: + """Product or aggregate key — on-plot labels only.""" + if seg.get("is_aggregate") and seg.get("aggregate_key"): + return str(seg["aggregate_key"]) + return str(seg.get("product") or "").strip() + + def _label_scale(self) -> float: + """Figure-width factor for label visibility and char budget.""" + if not self._canvas_has_size(): + return 1.0 + fig_w, _ = self.get_canvas_size_in_inches() + return max(0.7, min(1.6, fig_w / 6.0)) + + def _chars_for_fraction(self, frac: float, fontsize: float = 6.0) -> int: + """Rough char count for a label spanning ``frac`` of the figure width.""" + if frac <= 0: + return 0 + scale = self._label_scale() + if not self._canvas_has_size(): + return max(6, int(frac * 50 * scale)) + fig_w, _ = self.get_canvas_size_in_inches() + return max(6, int(fig_w * frac * 72 / (fontsize * 0.52) * scale)) + + @staticmethod + def _lines_for_row_height(row_h_in: float, fontsize: float) -> int: + """Wrap line count that fits in a tier-bar row height.""" + if row_h_in <= 0: + return 1 + line_h_in = (fontsize / 72.0) * 1.08 + return max(1, min(6, int(row_h_in / line_h_in))) + + def _tier_bar_text_layout( + self, + max_depth: int, + *, + row_height_frac: float = 0.85, + fontsize: float = 6.0, + ) -> tuple[int, float]: + """Shared wrap lines + font size from tier row height (width varies per bar).""" + if max_depth <= 0: + return 1, fontsize + if not self._canvas_has_size(): + return 3, fontsize + _, fig_h = self.get_canvas_size_in_inches() + row_h_in = (fig_h / max_depth) * row_height_frac + # Estimate line capacity at the smallest tier-bar font we use. + max_lines = self._lines_for_row_height(row_h_in, 4.5) + return max_lines, fontsize + + @staticmethod + def _tier_bar_fontsize(width: float, base: float) -> float: + """Smaller font in narrow bars so wrapped text stays inside horizontally.""" + if width < 0.04: + return min(base, 4.5) + if width < 0.08: + return min(base, 5.0) + if width < 0.12: + return min(base, 5.5) + return base + + def _tier_bar_chars_per_line(self, width: float, fontsize: float) -> int: + """Conservative chars per line for a bar's horizontal span.""" + usable = max(width * 0.92, 0.001) + chars = self._chars_for_fraction(usable, fontsize) + return max(3, int(chars * 0.55)) + + @staticmethod + def _icicle_column_fontsize(col_w: float, base: float) -> float: + """Smaller font in narrow tier columns.""" + if col_w < 0.08: + return min(base, 4.5) + if col_w < 0.15: + return min(base, 5.0) + if col_w < 0.22: + return min(base, 5.5) + return base + + def _icicle_chars_per_line(self, col_width_frac: float, fontsize: float) -> int: + """Chars per line from shared column width (same for every icicle cell).""" + usable = max(col_width_frac * 0.88, 0.001) + chars = self._chars_for_fraction(usable, fontsize) + return max(3, int(chars * 0.55)) + + def _icicle_max_lines(self, height_frac: float, fontsize: float) -> int: + """Wrap lines allowed by a cell's vertical span.""" + if height_frac <= 0: + return 1 + if not self._canvas_has_size(): + return max(2, min(6, int(height_frac * 50))) + _, fig_h = self.get_canvas_size_in_inches() + lines = self._lines_for_row_height(height_frac * fig_h, fontsize) + if height_frac >= 0.06: + lines = max(2, lines) + return lines + + @staticmethod + def _label_worth_showing(display: str) -> bool: + stripped = display.strip() + return bool(stripped) and stripped not in ("…", "...") + + @staticmethod + def _sunburst_tangent_rotation(mid_rad: float) -> float: + """Rotation (deg) for text aligned tangentially within a polar wedge.""" + deg = (np.degrees(mid_rad) + 360) % 360 + rotation = deg + if 90 < deg <= 270: + rotation += 180 + return rotation % 360 + + @staticmethod + def _sunburst_radial_rotation(mid_rad: float) -> float: + """Rotation (deg) for text aligned radially (outward from centre).""" + deg = (np.degrees(mid_rad) + 360) % 360 + rotation = deg - 90 + if 90 < deg <= 270: + rotation += 180 + return rotation % 360 + + def _chars_for_radial_span(self, r_span_frac: float, fontsize: float = 6.0) -> int: + """Char count for text running outward along a ring's radial thickness.""" + if r_span_frac <= 0: + return 0 + scale = self._label_scale() + if not self._canvas_has_size(): + return max(4, int(r_span_frac * 40 * scale)) + fig_w, fig_h = self.get_canvas_size_in_inches() + span_in = min(fig_w, fig_h) * 0.5 * r_span_frac + return max(4, int(span_in * 72 / (fontsize * 0.52) * scale)) + + def _chars_for_arc( + self, + radius_frac: float, + width_rad: float, + fontsize: float, + ) -> int: + """Char count for text running tangentially along a wedge arc.""" + if radius_frac <= 0 or width_rad <= 0: + return 0 + scale = self._label_scale() + if not self._canvas_has_size(): + return max(4, int(width_rad * radius_frac * 80 * scale)) + fig_w, fig_h = self.get_canvas_size_in_inches() + r_in = min(fig_w, fig_h) * 0.5 * radius_frac + arc_in = r_in * width_rad + return max(4, int(arc_in * 72 / (fontsize * 0.55) * scale * 0.88)) + + def _sunburst_ring_lines(self, ring_span: float, fontsize: float) -> int: + if not self._canvas_has_size(): + return 2 + fig_w, fig_h = self.get_canvas_size_in_inches() + ring_span_in = min(fig_w, fig_h) * 0.5 * ring_span + return min(3, max(1, self._lines_for_row_height(ring_span_in, fontsize))) + + def _sunburst_fontsize(self, frac: float) -> float: + if frac < 0.035: + return 4.0 + if frac < 0.06: + return 4.5 + if frac < 0.11: + return 5.0 + return 5.5 + + def _sunburst_label_layout( + self, + label: str, + *, + frac: float, + width_rad: float, + r0: float, + r1: float, + mid: float, + tier: int, + ring_w: float, + min_frac: float, + ) -> dict | None: + """Label geometry for one sunburst wedge, or None if too cramped.""" + if not label or frac <= min_frac: + return None + if tier == 0 and frac > 0.35: + return None + + ring_span = r1 - r0 + r_mid = (r0 + r1) * 0.5 + if frac < 0.018: + return None + + fontsize = self._sunburst_fontsize(frac) + ring_lines = self._sunburst_ring_lines(ring_span, fontsize) + arc_chars = self._chars_for_arc(r_mid, width_rad, fontsize) + + # Wide wedges: tangential text along the arc (more readable, uses angular width). + if frac >= 0.02 and arc_chars >= 5: + max_lines = 1 + if frac >= 0.045 and ring_lines >= 2: + max_lines = 2 + if frac >= 0.09 and ring_lines >= 3: + max_lines = 3 + chars_per_line = max( + 4, + int(arc_chars * (0.92 if max_lines == 1 else 0.82)), + ) + display = self._fit_label(label, chars_per_line, max_lines=max_lines) + if not self._label_worth_showing(display): + return None + return { + "x": mid, + "y": r_mid, + "s": display, + "ha": "center", + "va": "center", + "fontsize": fontsize, + "rotation": self._sunburst_tangent_rotation(mid), + "rotation_mode": "anchor", + } + + radial_chars = self._chars_for_radial_span(ring_span * 0.88, fontsize) + if radial_chars < 3: + return None + max_lines = 2 if ring_lines >= 2 and ring_span > ring_w * 0.3 else 1 + display = self._fit_label_chars( + label, + max(3, radial_chars), + max_lines=max_lines, + ) + if not self._label_worth_showing(display): + return None + return { + "x": mid, + "y": r0 + ring_span * 0.14, + "s": display, + "ha": "left", + "va": "center", + "fontsize": fontsize, + "rotation": self._sunburst_radial_rotation(mid), + "rotation_mode": "anchor", + } + + @staticmethod + def _wrap_label_words(text: str, width: int) -> str: + return textwrap.fill( + text, + width=max(4, width), + break_long_words=False, + replace_whitespace=False, + ) + + @staticmethod + def _clip_text_to_patch(txt, patch) -> None: + try: + txt.set_clip_path(patch.get_path(), patch.get_transform()) + except (TypeError, AttributeError): + txt.set_clip_path(patch) + + @staticmethod + def _fit_label_chars(text: str, max_chars: int, max_lines: int = 1) -> str: + """Fixed-width character breaks (may split mid-word).""" + if not text: + return "" + w = max(1, max_chars) + if max_lines <= 1: + return text if len(text) <= w else text[: w - 1] + "…" + lines: list[str] = [] + pos = 0 + for line_no in range(max_lines): + if pos >= len(text): + break + if line_no == max_lines - 1: + tail = text[pos:] + lines.append(tail if len(tail) <= w else tail[: w - 1] + "…") + break + lines.append(text[pos : pos + w]) + pos += w + return "\n".join(lines) + + @staticmethod + def _fit_label(text: str, max_chars: int, max_lines: int = 1) -> str: + """Word-aware truncate or wrap for on-plot segment labels.""" + if not text: + return "" + width = max(4, max_chars) + if max_lines <= 1: + return textwrap.shorten(text, width=width, placeholder="…") + wrapped = ContributionTreePlot._wrap_label_words(text, width) + lines = wrapped.splitlines() + if len(lines) <= max_lines: + return wrapped + kept = lines[: max_lines - 1] + remainder = " ".join(lines[max_lines - 1 :]) + kept.append(textwrap.shorten(remainder, width=width, placeholder="…")) + return "\n".join(kept) def plot(self) -> None: + self._hover_targets = [] if self._state is None or self._total_score == 0.0: - self.figure.clear() - self.canvas.draw_idle() + self.show_empty() + return + if not self._segments: + self.show_empty("Use Adjust to explore the supply chain.") return - rings = build_sunburst_rings( - self._state.nodes, - self._state.edges, - self._total_score, - max_depth=self._plot_depth, - root_uid=self._state._root_node.unique_id, - ) - if not rings: - self.figure.clear() - self.canvas.draw_idle() + dispatch = { + PLOT_SUNBURST: self._plot_sunburst, + PLOT_TIER_BARS: self._plot_tier_bars, + PLOT_ICICLE: self._plot_icicle, + } + dispatch.get(self._mode, self._plot_icicle)() + self.finish_plot(on_hover=self._hover_callback, on_click=self._click_callback) + + + def _segment_at(self, event) -> dict | None: + if event.inaxes is None: + return None + if self._mode == PLOT_SUNBURST: + return self._sunburst_segment_at(event) + for artist, seg in reversed(self._hover_targets): + try: + inside, _ = artist.contains(event) + except Exception: + continue + if inside: + return seg + return None + + def _click_callback(self, event) -> None: + if self._on_segment_clicked is None: return + seg = self._segment_at(event) + if seg is not None: + self._on_segment_clicked(seg) + + def _hover_callback(self, event): + if event.inaxes is None: + return None + seg = self._segment_at(event) + if seg is not None: + return self._format_segment_tooltip(seg) + return None + + def _sunburst_segment_at(self, event): + """Polar hit-test — inner-ring (tier 0) wedges miss ``contains()`` on bar patches.""" + if event.xdata is None or event.ydata is None: + return None + max_depth = self._plot_depth + ring_w = 1.0 / (max_depth + 1) + r = event.ydata + tier = None + for t in range(max_depth): + r0 = ring_w * (t + 0.05) + r1 = ring_w * (t + 0.95) + if r0 <= r <= r1: + tier = t + break + if tier is None: + return None + theta = float(event.xdata) % (2 * np.pi) + for seg in self._segments: + if seg["tier"] != tier: + continue + t0 = seg["x0"] * 2 * np.pi + t1 = seg["x1"] * 2 * np.pi + if t0 <= theta <= t1: + return seg + return None + @staticmethod + def _format_abs(value: float) -> str: + a = abs(value) + if a >= 100: + return f"{value:.2f}" + if a >= 1: + return f"{value:.3f}" + if a >= 0.01: + return f"{value:.4f}" + return f"{value:.2e}" + + def _format_segment_tooltip(self, seg: dict) -> str: + unit = self._unit or seg.get("unit") or "" + lines = [] + if seg.get("is_aggregate"): + field = seg.get("aggregate_by", "") + label = PLOT_AGGREGATE_LABELS.get(field, field) + if label and seg.get("aggregate_key"): + lines.append(f"{label}: {seg['aggregate_key']}") + n = len(seg.get("constituent_uids") or []) + if n: + lines.append(f"Processes: {n}") + products = seg.get("constituent_products") or [] + for name in products[:5]: + lines.append(f"• {name}") + if len(products) > 5: + lines.append(f"… and {len(products) - 5} more") + else: + if seg.get("product"): + lines.append(f"Product: {seg['product']}") + if seg.get("process"): + lines.append(f"Process: {seg['process']}") + if seg.get("location"): + lines.append(f"Location: {seg['location']}") + if seg.get("database"): + lines.append(f"Database: {seg['database']}") + lines.append(f"Tier: {seg['tier']}") + lines.append( + f"Path impact: {seg['cumulative_pct']:.2f}% " + f"({self._format_abs(seg['cumulative_score'])} {unit})".rstrip() + ) + lines.append( + f"Direct impact: {seg['direct_pct']:.2f}% " + f"({self._format_abs(seg['direct_emissions_score'])} {unit})".rstrip() + ) + return "\n".join(lines) + + + def _register_hover(self, artist, seg: dict) -> None: + self._hover_targets.append((artist, seg)) + + def _plot_sunburst(self) -> None: self.figure.clear() ax = self.figure.add_subplot(111, polar=True) ax.set_theta_zero_location("N") ax.set_theta_direction(-1) ax.set_axis_off() - n_rings = len(rings) - ring_width = 1.0 / (n_rings + 1) # leave space for centre label - - import numpy as np - import matplotlib - - cmap = matplotlib.colormaps["tab20c"] - - for ring_idx, ring in enumerate(rings): - bottom = ring_width * (ring_idx + 1) - - # Track angular position for each parent - # We need to lay out wedges respecting parent arc positions. - # Build per-parent wedge lists - by_parent: dict = {} - for w in ring: - by_parent.setdefault(w["parent_unique_id"], []).append(w) - - # For tier-1 ring: parent is root, arc starts at 0, full circle - # For deeper rings: use parent wedge start angles (stored per uid) - if ring_idx == 0: - parent_starts = {list(by_parent.keys())[0]: 0.0} - parent_spans = {list(by_parent.keys())[0]: 2 * np.pi} - else: - parent_starts = getattr(self, "_wedge_starts", {}) - parent_spans = getattr(self, "_wedge_spans", {}) - - new_starts: dict = {} - new_spans: dict = {} - - for parent_uid, wedges in by_parent.items(): - p_start = parent_starts.get(parent_uid, 0.0) - p_span = parent_spans.get(parent_uid, 2 * np.pi) - - theta = p_start - for i, w in enumerate(wedges): - arc = w["share"] * p_span - colour = ( - (0.7, 0.7, 0.7, 0.5) - if w["is_other"] - else cmap((ring_idx * 7 + i) % 20 / 20) - ) - ax.bar( - x=theta, - width=arc, - bottom=bottom, - height=ring_width * 0.9, - color=colour, - edgecolor="white", - linewidth=0.5, - align="edge", - ) - if not w["is_other"] and arc > 0.2: - label = str(w.get("label", ""))[:20] - mid = theta + arc / 2 - ax.text( - mid, - bottom + ring_width * 0.45, - label, - ha="center", - va="center", - fontsize=6, - rotation=0, - clip_on=True, - ) - new_starts[w["unique_id"]] = theta - new_spans[w["unique_id"]] = arc - theta += arc - - self._wedge_starts = new_starts - self._wedge_spans = new_spans - - # Centre label + max_depth = self._plot_depth + ring_w = 1.0 / (max_depth + 1) + scale = self._label_scale() + min_frac = 0.028 / scale + + for seg in self._segments: + tier = seg["tier"] + r0 = ring_w * (tier + 0.05) + r1 = ring_w * (tier + 0.95) + theta = seg["x0"] * 2 * np.pi + width = (seg["x1"] - seg["x0"]) * 2 * np.pi + colour = self._segment_color(seg["direct_pct"]) + bar = ax.bar( + x=theta, + width=width, + bottom=r0, + height=ring_w * 0.9, + color=colour, + edgecolor=SEGMENT_EDGE_COLOR, + linewidth=SEGMENT_EDGE_WIDTH, + align="edge", + ) + patch = bar.patches[0] + self._register_hover(patch, seg) + + label = self._segment_label(seg) + frac = width / (2 * np.pi) + layout = self._sunburst_label_layout( + label, + frac=frac, + width_rad=width, + r0=r0, + r1=r1, + mid=theta + width / 2, + tier=tier, + ring_w=ring_w, + min_frac=min_frac, + ) + if layout is None: + continue + txt = ax.text( + layout["x"], + layout["y"], + layout["s"], + ha=layout["ha"], + va=layout["va"], + fontsize=layout["fontsize"], + color=self._theme_text_color(), + rotation=layout["rotation"], + rotation_mode=layout["rotation_mode"], + clip_on=True, + ) + self._clip_text_to_patch(txt, patch) + ax.text( - 0, 0, - f"Tier {self._plot_depth}", - ha="center", va="center", + 0, + 0, + "RF", + ha="center", + va="center", fontsize=8, - transform=ax.transData, + color=self._theme_text_color(), ) - self.finish_plot() + def _plot_tier_bars(self) -> None: + self.figure.clear() + ax = self.figure.add_subplot(111) + row_h = 1.0 + bar_height_frac = 0.85 + max_depth = self._plot_depth + scale = self._label_scale() + min_frac = 0.025 / scale + max_lines, base_fontsize = self._tier_bar_text_layout( + max_depth, row_height_frac=bar_height_frac, fontsize=6.0 + ) + + for tier in range(max_depth): + ax.text( + -0.02, + tier, + f"Tier {tier}", + ha="right", + va="center", + fontsize=8, + color=self._theme_text_color(), + ) + + for seg in self._segments: + tier = seg["tier"] + x0, x1 = seg["x0"], seg["x1"] + width = max(x1 - x0, 0.003) + if x1 - x0 < 0.003: + x0 = (seg["x0"] + seg["x1"]) / 2 - width / 2 + colour = self._segment_color(seg["direct_pct"]) + rect = ax.barh( + tier, + width, + left=x0, + height=row_h * bar_height_frac, + color=colour, + edgecolor=SEGMENT_EDGE_COLOR, + linewidth=SEGMENT_EDGE_WIDTH, + align="center", + ) + bar_patches = list(rect.patches) + for patch in bar_patches: + self._register_hover(patch, seg) + label = self._segment_label(seg) + if not label or width <= min_frac or not bar_patches: + continue + patch = bar_patches[0] + fontsize = self._tier_bar_fontsize(width, base_fontsize) + chars_per_line = self._tier_bar_chars_per_line(width, fontsize) + if chars_per_line < 3: + continue + display = self._fit_label_chars(label, chars_per_line, max_lines=max_lines) + if not self._label_worth_showing(display): + continue + txt = ax.text( + x0 + width / 2, + tier, + display, + ha="center", + va="center", + fontsize=fontsize, + color=self._theme_text_color(), + clip_on=True, + ) + self._clip_text_to_patch(txt, patch) + + ax.set_xlim(0, 1) + ax.set_ylim(max_depth - 0.5, -0.5) + ax.set_yticks([]) + ax.set_xticks([]) + for spine in ax.spines.values(): + spine.set_visible(False) + + def _plot_icicle(self) -> None: + self.figure.clear() + ax = self.figure.add_subplot(111) + max_depth = self._plot_depth + col_w = 1.0 / max_depth + scale = self._label_scale() + min_height = 0.02 / scale + col_width_frac = col_w * 0.97 + base_fontsize = 6.0 + col_fontsize = self._icicle_column_fontsize(col_w, base_fontsize) + chars_per_line = self._icicle_chars_per_line(col_width_frac, col_fontsize) + cell_w = col_w * 0.97 + + for tier in range(max_depth): + ax.text( + (tier + 0.5) * col_w, + 1.02, + f"Tier {tier}", + ha="center", + va="bottom", + fontsize=8, + color=self._theme_text_color(), + transform=ax.get_xaxis_transform(), + ) + + for seg in self._segments: + tier = seg["tier"] + x0 = tier * col_w + y0 = seg["x0"] + height = seg["x1"] - seg["x0"] + colour = self._segment_color(seg["direct_pct"]) + rect = Rectangle( + (x0, y0), + cell_w, + height, + facecolor=colour, + edgecolor=SEGMENT_EDGE_COLOR, + linewidth=SEGMENT_EDGE_WIDTH, + ) + rect.set_transform(ax.transData) + ax.add_patch(rect) + self._register_hover(rect, seg) + label = self._segment_label(seg) + if not label or height <= min_height: + continue + fontsize = col_fontsize + if height < 0.04: + fontsize = min(fontsize, 4.5) + elif height < 0.07: + fontsize = min(fontsize, 5.0) + max_lines = self._icicle_max_lines(height * 0.95, fontsize) + if len(label) > chars_per_line and max_lines < 2: + max_lines = 2 + display = self._fit_label_chars(label, chars_per_line, max_lines=max_lines) + if not self._label_worth_showing(display): + continue + txt = ax.text( + x0 + cell_w / 2, + y0 + height / 2, + display, + va="center", + ha="center", + fontsize=fontsize, + color=self._theme_text_color(), + clip_on=True, + ) + self._clip_text_to_patch(txt, rect) + + ax.set_xlim(0, 1) + ax.set_ylim(1, 0) + ax.set_xticks([]) + ax.set_yticks([]) + for spine in ax.spines.values(): + spine.set_visible(False) + def export_figure(self, path: str, selected_filter: str = "") -> None: + """Save the current matplotlib plot to disk.""" + lower = path.lower() + if not lower.endswith((".png", ".svg")): + path += ".svg" if "SVG" in selected_filter else ".png" + self.figure.savefig(path, bbox_inches="tight") diff --git a/activity_browser/app/pages/lca_results/contribution_tree_tab.py b/activity_browser/app/pages/lca_results/contribution_tree_tab.py index c089417dc..814bb8332 100644 --- a/activity_browser/app/pages/lca_results/contribution_tree_tab.py +++ b/activity_browser/app/pages/lca_results/contribution_tree_tab.py @@ -1,7 +1,6 @@ """Contribution Tree tab for the LCA Results page. -Shows the contribution tree as a hierarchical QTreeView (one row per -traversed upstream supplier) with a sunburst plot above it. +Shows the contribution tree as a hierarchical table with an optional supply-chain plot. """ from __future__ import annotations @@ -23,8 +22,12 @@ from activity_browser import app from activity_browser.bwutils.contribution_tree import ( + PLOT_AGGREGATE_FIELDS, + PLOT_AGGREGATE_LABELS, compute_node_tiers, direct_impact_coverage, + is_terminal_node, + plot_click_target_uid, run_expand_policy, suppress_graph_traversal_warnings, ) @@ -46,50 +49,47 @@ UID_ROLE, ContributionTreeModel, ) -from .contribution_tree_plot import SunburstPlot +from .contribution_tree_plot import ( + PLOT_ICICLE, + PLOT_MODES, + ContributionTreePlot, +) from .style import SmallComboBox, apply_lca_combo_width, lca_header_layout, lca_help_tool_button, lca_tab_control_row HELP_TEXT = """ - Contribution Tree shows how impact accumulates along the supply chain -of one reference flow and impact category (and scenario, when present).
+Tree shows how impact accumulates along the supply chain for one +reference flow, impact category, and scenario.
-Tree table
+
-Each row is a process on a supply path. Cumulative impact (%) is the share -of the total score that flows through that path (path impact). -Direct impact (%) is only the characterised emissions of that process itself. -The reference flow is tier 0; its suppliers are tier 1, and so on. -Expand a row manually to calculate and list all of its suppliers.Table
+Each row is a process on a supply path. Cumulative impact (%) is +path impact (that process plus all upstream). Direct impact (%) is +only characterised emissions at that process. Tier 0 is the reference +flow; tier 1 its direct suppliers, and so on. Expand a row to calculate +and list its suppliers.Cutoff
- -
-Engine threshold for Brightway graph traversal: branches whose path impact is -below this percent of the total score are not followed further during calculation.Expand to
- -
-• Tier — calculate and open the tree down to the chosen tier.
-• Individual path impact — calculate and open every node whose -path (cumulative) impact is at least X% of the total and that still -has a child ≥ X% (the high-impact path continues). Under those opened nodes -list all discovered siblings (including below X%). A terminal ≥ X% node -stays collapsed until you expand it manually. Only the engine Cutoff -omits smaller branches from calculation.
-• Cumulative impact — from the reference flow, open the -largest paths first until Σ(direct impact) of the rows in that tree -reaches X% of the total. Children of an opened node are added largest-first -and stop once the target is met (collapse and re-expand a row to list every -child). Further Brightway traversal runs only when the next node to open -is not yet calculated. If the engine cutoff stops discovery early, the -footer shows that the target was not reached.Sunburst
+During calculation, branches below this share of the total score are not +followed further.
-Layers match tiers. Plot tiers controls how many rings are drawn; it does -not change the table.Adjust to
+ +
+• Tier — open the tree to a chosen tier.
+• Individual path impact — open nodes whose path impact meets the +threshold and still have a qualifying child; list siblings under opened +nodes. A terminal node above the threshold stays collapsed until you expand +it.
+• Cumulative impact — open the largest paths first until the direct +impact of visible rows reaches the target.Plot
+Tier-stacked bars or icicle — mirrors the visible tree. Click a segment to +expand or collapse that branch (merged bands toggle the parent row). +Aggregate by rolls up sibling segments in the plot only. Hover for +product, process, path and direct impact.Footer
+Shown — visible rows, their direct-impact share, deepest visible tier. +Calculated — all nodes found by traversal, coverage, deepest tier.
-Shown = visible rows, their direct-impact share, and deepest visible -tier (updates on expand/collapse). Calculated = all nodes discovered -by graph traversal, their direct-impact coverage, and deepest calculated -tier.Date: Wed, 12 Aug 2026 17:51:54 +0200 Subject: [PATCH 10/10] Fifth, refined working version of Contribution Tree tab. --- CONTEXT.md | 4 +- .../lca_results/contribution_tree_model.py | 42 +---------------- .../lca_results/contribution_tree_plot.py | 46 ++++++++++--------- .../lca_results/contribution_tree_tab.py | 8 +--- activity_browser/bwutils/contribution_tree.py | 14 +----- tests/test_contribution_tree.py | 11 +---- tests/test_contribution_tree_plot.py | 11 +++++ 7 files changed, 45 insertions(+), 91 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 4838ea3ac..4df5a86e7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -125,7 +125,7 @@ Extensibility mechanism for third-party AB features. **Architecture TBD** — do ### Contribution tree -A hierarchical, acyclic breakdown of LCA impact by upstream supplier, produced by priority-first graph traversal (`SameNodeEachVisitGraphTraversal`). Each node carries a **cumulative impact** (its own direct emissions plus all upstream) and a **direct impact** (its own biosphere flows only). The root is the functional unit; children are direct technosphere suppliers, recursed up the supply chain. Shown in AB as a `QTreeView` with one row per traversed node, in the "Contribution Tree" tab of the LCA Results page. Nodes are calculated lazily on expand; the **adjust policy** controls how far the Adjust control walks the tree. +A hierarchical, acyclic breakdown of LCA impact by upstream supplier, produced by priority-first graph traversal (`SameNodeEachVisitGraphTraversal`). Each node carries a **cumulative impact** (its own direct emissions plus all upstream) and a **direct impact** (its own biosphere flows only). The root is the functional unit; children are direct technosphere suppliers, recursed up the supply chain. Shown in AB as a `QTreeView` with one row per traversed node, in the **Tree** tab of the LCA Results page. Nodes are calculated lazily on expand; the **adjust policy** controls how far the Adjust control walks the tree. _Avoid_: supply-chain tree, upstream tree (use contribution tree in AB UI; "upstream tree" is the OpenLCA term for the same concept) ### Tier (contribution-tree depth) @@ -161,7 +161,7 @@ _Avoid_: individual impact (alone), branch score ### Adjust policy -How far the **Adjust to** control calculates and visually opens the contribution tree. Modes: **Tier** (open down to a given tier), **Individual path impact** (keep expanding while path impact ≥ X% continues into a child; list all discovered children under opened nodes; leave terminal ≥ X% rows collapsed), **Cumulative impact** (largest-first from the reference flow until the **display set**’s direct-impact coverage reaches a target %, capped below 100% — does not open every previously calculated node). Distinct from a later optional **display filter** that only hides already-calculated rows. Open branches and which rows are in the tree are remembered per RF / impact category / scenario / cutoff when switching selections in the Contribution Tree tab. +How far the **Adjust to** control calculates and visually opens the contribution tree. Modes: **Tier** (open down to a given tier), **Individual path impact** (keep expanding while path impact ≥ X% continues into a child; list all discovered children under opened nodes; leave terminal ≥ X% rows collapsed), **Cumulative impact** (largest-first from the reference flow until the **display set**’s direct-impact coverage reaches a target %, capped below 100% — does not open every previously calculated node). Distinct from a later optional **display filter** that only hides already-calculated rows. Open branches and which rows are in the tree are remembered per RF / impact category / scenario / cutoff when switching selections in the Tree tab. _Avoid_: cutoff (alone — ambiguous with Process Contributions and engine traversal cutoff); expand policy (legacy UI label — use adjust policy) ### Plot–tree linking diff --git a/activity_browser/app/pages/lca_results/contribution_tree_model.py b/activity_browser/app/pages/lca_results/contribution_tree_model.py index 2173c6d9f..48d5315f7 100644 --- a/activity_browser/app/pages/lca_results/contribution_tree_model.py +++ b/activity_browser/app/pages/lca_results/contribution_tree_model.py @@ -244,46 +244,6 @@ def expand_node(self, unique_id: int) -> bool: finally: self._expanding = False - def restrict_to_uids(self, keep: set[int]) -> None: - """Remove rows whose unique_id is not in ``keep`` (deepest first). - - Used when restoring a cached view after path/cumulative display-set - restrict (or any filter that left a subset of the traversal in the model). - """ - if self._state is None: - return - to_remove = [uid for uid in self._uid_to_item if uid not in keep] - to_remove.sort( - key=lambda u: int(self._uid_to_item[u].data(TIER_ROLE) or 0), - reverse=True, - ) - for uid in to_remove: - item = self._uid_to_item.get(uid) - if item is None: - continue - parent = item.parent() - if parent is None: - parent = self.invisibleRootItem() - row = item.row() - self._forget_subtree(item) - parent.removeRow(row) - - pcm = self._parent_child_map() - for uid, item in list(self._uid_to_item.items()): - if self.has_real_children(item): - continue - if self._has_hidden_children(uid, pcm): - self._ensure_placeholder(item) - - def _forget_subtree(self, item: QtGui.QStandardItem) -> None: - for row in range(item.rowCount()): - child = item.child(row, 0) - if child is not None and not child.data(PLACEHOLDER_ROLE): - self._forget_subtree(child) - uid = item.data(UID_ROLE) - if uid is not None: - self._uid_to_item.pop(uid, None) - def _has_hidden_children(self, unique_id: int, pcm: dict | None = None) -> bool: if self._state is None: return False @@ -299,6 +259,8 @@ def to_dataframe(self, metadata_lookup=None): if self._state is None: import pandas as pd return pd.DataFrame(columns=COLUMNS) + if metadata_lookup is None: + metadata_lookup = self.lookup_activity_meta return flatten_to_dataframe( self._state.nodes, self._state.edges, diff --git a/activity_browser/app/pages/lca_results/contribution_tree_plot.py b/activity_browser/app/pages/lca_results/contribution_tree_plot.py index 02af739f2..e588352b7 100644 --- a/activity_browser/app/pages/lca_results/contribution_tree_plot.py +++ b/activity_browser/app/pages/lca_results/contribution_tree_plot.py @@ -1,4 +1,4 @@ -"""Contribution Tree plots — tier-stacked bars and icicle (sunburst retained, not in UI).""" +"""Contribution Tree plots — Vertical / Horizontal tiers (sunburst retained, not in UI).""" from __future__ import annotations @@ -15,7 +15,6 @@ PLOT_AGGREGATE_LABELS, build_plot_segments, direct_impact_intensity, - direct_impact_rgba, ) from activity_browser.ui import widgets @@ -26,19 +25,34 @@ # Sunburst kept in code but hidden from the UI for now. PLOT_MODES_ALL = ( (PLOT_SUNBURST, "Sunburst"), - (PLOT_TIER_BARS, "Tier-stacked bars"), - (PLOT_ICICLE, "Icicle"), + (PLOT_TIER_BARS, "Vertical tiers"), + (PLOT_ICICLE, "Horizontal tiers"), ) PLOT_MODES = ( - (PLOT_TIER_BARS, "Tier-stacked bars"), - (PLOT_ICICLE, "Icicle"), + (PLOT_TIER_BARS, "Vertical tiers"), + (PLOT_ICICLE, "Horizontal tiers"), ) # Re-applied after theme sync (ABPlot otherwise sets edges to axes facecolor). SEGMENT_EDGE_COLOR = "white" SEGMENT_EDGE_WIDTH = 0.25 +# Match Contribution Tree direct-impact column tint (blue burden / green credit). +_BURDEN_RGB = (70 / 255, 130 / 255, 210 / 255) +_CREDIT_RGB = (85 / 255, 170 / 255, 95 / 255) + + +def direct_impact_rgba( + direct_pct: float, + max_direct_pct: float, +) -> tuple[float, float, float, float]: + """RGBA for plot segments — blue burdens, green credits.""" + frac = direct_impact_intensity(direct_pct, max_direct_pct) + alpha = 0.12 + 0.82 * frac + r, g, b = _CREDIT_RGB if direct_pct < 0 else _BURDEN_RGB + return (r, g, b, alpha) + class ContributionTreePlot(widgets.ABPlot): """Supply-chain plots for the Contribution Tree tab. @@ -168,20 +182,6 @@ def set_state( self._max_direct_pct = 100.0 self.plot() - def update_depth(self, plot_depth: int) -> None: - """Refresh segments when tree depth changes (no separate UI control).""" - self._plot_depth = max(1, plot_depth) - if self._state is not None: - self.set_state( - self._state, - self._total_score, - self._plot_depth, - metadata_lookup=None, - unit=self._unit, - included_uids=self._included_uids, - aggregate_by=self._aggregate_by, - ) - @staticmethod def _segment_label(seg: dict) -> str: """Product or aggregate key — on-plot labels only.""" @@ -822,4 +822,8 @@ def export_figure(self, path: str, selected_filter: str = "") -> None: lower = path.lower() if not lower.endswith((".png", ".svg")): path += ".svg" if "SVG" in selected_filter else ".png" - self.figure.savefig(path, bbox_inches="tight") + # Screen figures are often ~100 dpi; export PNG at print-quality dpi. + kwargs = {"bbox_inches": "tight"} + if path.lower().endswith(".png"): + kwargs["dpi"] = 300 + self.figure.savefig(path, **kwargs) diff --git a/activity_browser/app/pages/lca_results/contribution_tree_tab.py b/activity_browser/app/pages/lca_results/contribution_tree_tab.py index 814bb8332..75b47b3fb 100644 --- a/activity_browser/app/pages/lca_results/contribution_tree_tab.py +++ b/activity_browser/app/pages/lca_results/contribution_tree_tab.py @@ -82,7 +82,7 @@ impact of visible rows reaches the target. Plot
@@ -1004,10 +1004,6 @@ def _visible_row_stats(self) -> tuple[int, float, int, set[int]]: coverage = direct_sum / abs(total) if total else 0.0 return shown_n, coverage, max_tier, visible_uids - def _visible_tree_uids(self) -> set[int]: - """Unique ids of rows currently shown in the tree view.""" - return self._visible_row_stats()[3] - def _reload_plot( self, row_stats: tuple[int, float, int, set[int]] | None = None, @@ -1121,7 +1117,7 @@ def _max_calculated_tier(self) -> int: def _export_table(self) -> None: if self._current_state is None: return - df = self._tree_model.to_dataframe(metadata_lookup=None) + df = self._tree_model.to_dataframe() if df.empty: return default_name = ( diff --git a/activity_browser/bwutils/contribution_tree.py b/activity_browser/bwutils/contribution_tree.py index 8dce6e683..c2bdbc395 100644 --- a/activity_browser/bwutils/contribution_tree.py +++ b/activity_browser/bwutils/contribution_tree.py @@ -553,18 +553,6 @@ def direct_impact_intensity( return (math.log10(v) - math.log10(lo)) / (math.log10(hi) - math.log10(lo)) -def direct_impact_rgba( - direct_pct: float, - max_direct_pct: float, -) -> tuple[float, float, float, float]: - """RGBA for plot segments — blue burdens, green credits.""" - frac = direct_impact_intensity(direct_pct, max_direct_pct) - alpha = 0.12 + 0.82 * frac - if direct_pct < 0: - return (85 / 255, 170 / 255, 95 / 255, alpha) - return (70 / 255, 130 / 255, 210 / 255, alpha) - - def _parent_uid(child_uid: NodeId, edges: list, root_uid: NodeId) -> NodeId: parent = _find_parent(child_uid, edges) return root_uid if parent is None else parent @@ -587,7 +575,7 @@ def _upstream_layout_span( # --------------------------------------------------------------------------- -# Supply-chain layout (sunburst / tier bars / icicle) +# Supply-chain layout (sunburst / vertical tiers / horizontal tiers) # --------------------------------------------------------------------------- def build_chain_layout( diff --git a/tests/test_contribution_tree.py b/tests/test_contribution_tree.py index 013d8c707..8f4a3dedc 100644 --- a/tests/test_contribution_tree.py +++ b/tests/test_contribution_tree.py @@ -18,7 +18,6 @@ build_sunburst_rings, coverage_of_uids, cumulative_percent, - direct_impact_rgba, direct_impact_intensity, direct_impact_coverage, direct_percent, @@ -295,8 +294,6 @@ def test_chain_layout_negative_impact_has_positive_span(): assert burden["x1"] - burden["x0"] == pytest.approx(0.8) assert credit["cumulative_score"] == pytest.approx(-2.0) assert credit["direct_pct"] == pytest.approx(-20.0) - r, g, b, a = direct_impact_rgba(credit["direct_pct"], 20.0) - assert g > r # green credit tint def test_chain_layout_credit_when_parent_direct_exceeds_cumulative(): @@ -348,6 +345,8 @@ def test_aggregate_plot_segments_merges_siblings_by_location(): assert tier1[0]["toggle_uid"] == 0 assert set(tier1[0]["constituent_uids"]) == {1, 2} assert tier1[0]["cumulative_score"] == pytest.approx(9.0) + assert tier1[0]["direct_emissions_score"] == pytest.approx(2.0) + assert tier1[0]["direct_pct"] == pytest.approx(20.0) assert tier1[0]["x0"] == pytest.approx(0.0) assert tier1[0]["x1"] == pytest.approx(0.7) @@ -382,12 +381,6 @@ def test_aggregate_plot_segments_none_preserves_segments(): assert all(s["toggle_uid"] == s["unique_id"] for s in out) -def test_direct_impact_rgba_positive(): - r, g, b, a = direct_impact_rgba(50.0, 100.0) - assert r == pytest.approx(70 / 255) - assert a > 0.5 - - def test_direct_impact_intensity_log_scale(): lo = direct_impact_intensity(1.0, 100.0) mid = direct_impact_intensity(10.0, 100.0) diff --git a/tests/test_contribution_tree_plot.py b/tests/test_contribution_tree_plot.py index d65277422..42122bee2 100644 --- a/tests/test_contribution_tree_plot.py +++ b/tests/test_contribution_tree_plot.py @@ -1,9 +1,11 @@ """Tests for Contribution Tree plot label helpers (no Qt).""" import numpy as np +import pytest from activity_browser.app.pages.lca_results.contribution_tree_plot import ( ContributionTreePlot, + direct_impact_rgba, ) @@ -68,3 +70,12 @@ def test_fit_label_chars_breaks_mid_word(): def test_icicle_column_fontsize(): assert ContributionTreePlot._icicle_column_fontsize(0.25, 6.0) == 6.0 assert ContributionTreePlot._icicle_column_fontsize(0.1, 6.0) == 5.0 + + +def test_direct_impact_rgba_burden_and_credit(): + r, g, b, a = direct_impact_rgba(50.0, 100.0) + assert r == pytest.approx(70 / 255) + assert a > 0.5 + cr, cg, cb, ca = direct_impact_rgba(-50.0, 100.0) + assert cg > cr + assert ca > 0.5
-Tier-stacked bars or icicle — mirrors the visible tree. Click a segment to +Vertical tiers or Horizontal tiers — mirrors the visible tree. Click a segment to expand or collapse that branch (merged bands toggle the parent row). Aggregate by rolls up sibling segments in the plot only. Hover for product, process, path and direct impact.